Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
kentcdodds committed Jun 15, 2020
2 parents 136ac13 + e13df95 commit e72205a
Show file tree
Hide file tree
Showing 38 changed files with 3,595 additions and 2,125 deletions.
110 changes: 68 additions & 42 deletions README.md
Expand Up @@ -52,16 +52,17 @@ change the state of the checkbox.

- [Installation](#installation)
- [API](#api)
- [`click(element)`](#clickelement)
- [`dblClick(element)`](#dblclickelement)
- [`async type(element, text, [options])`](#async-typeelement-text-options)
- [`click(element, eventInit, options)`](#clickelement-eventinit-options)
- [`dblClick(element, eventInit, options)`](#dblclickelement-eventinit-options)
- [`type(element, text, [options])`](#typeelement-text-options)
- [`upload(element, file, [{ clickInit, changeInit }])`](#uploadelement-file--clickinit-changeinit-)
- [`clear(element)`](#clearelement)
- [`selectOptions(element, values)`](#selectoptionselement-values)
- [`toggleSelectOptions(element, values)`](#toggleselectoptionselement-values)
- [`deselectOptions(element, values)`](#deselectoptionselement-values)
- [`tab({shift, focusTrap})`](#tabshift-focustrap)
- [`async hover(element)`](#async-hoverelement)
- [`async unhover(element)`](#async-unhoverelement)
- [`hover(element)`](#hoverelement)
- [`unhover(element)`](#unhoverelement)
- [`paste(element, text, eventInit, options)`](#pasteelement-text-eventinit-options)
- [Issues](#issues)
- [Contributors ✨](#contributors-)
- [LICENSE](#license)
Expand Down Expand Up @@ -94,7 +95,7 @@ var userEvent = require('@testing-library/user-event')

## API

### `click(element)`
### `click(element, eventInit, options)`

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

### `dblClick(element)`
Note that `click` will trigger hover events before clicking. To disable this,
set the `skipHover` option to `true`.

### `dblClick(element, eventInit, options)`

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

### `async type(element, text, [options])`
### `type(element, text, [options])`

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

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

test('type', async () => {
test('type', () => {
render(<textarea />)

await userEvent.type(screen.getByRole('textbox'), 'Hello,{enter}World!')
userEvent.type(screen.getByRole('textbox'), 'Hello,{enter}World!')
expect(screen.getByRole('textbox')).toHaveValue('Hello,\nWorld!')
})
```

If `options.allAtOnce` is `true`, `type` will write `text` at once rather than
one character at the time. `false` is the default value.

`options.delay` is the number of milliseconds that pass between two characters
are typed. By default it's 0. You can use this option if your component has a
different behavior for fast or slow users.
different behavior for fast or slow users. If you do this, you need to make sure
to `await`!

`type` will click the element before typing. To disable this, set the
`skipClick` option to `true`.

#### Special characters

The following special character strings are supported:

| Text string | Key | Modifier | Notes |
| ------------- | --------- | ---------- | ---------------------------------------------------------------------------------- |
| `{enter}` | Enter | N/A | Will insert a newline character (`<textarea />` only). |
| `{esc}` | Escape | N/A | |
| `{backspace}` | Backspace | N/A | Will delete the previous character (or the characters within the `selectedRange`). |
| `{shift}` | Shift | `shiftKey` | Does **not** capitalize following characters. |
| `{ctrl}` | Control | `ctrlKey` | |
| `{alt}` | Alt | `altKey` | |
| `{meta}` | OS | `metaKey` | |
| Text string | Key | Modifier | Notes |
| ------------- | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{enter}` | Enter | N/A | Will insert a newline character (`<textarea />` only). |
| `{esc}` | Escape | N/A | |
| `{backspace}` | Backspace | N/A | Will delete the previous character (or the characters within the `selectedRange`). |
| `{del}` | Delete | N/A | Will delete the next character (or the characters within the `selectedRange`) |
| `{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) |
| `{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 or until they are closed (via `{/shift}`, `{/ctrl}`, etc.).
> of type command or until they are closed (via `{/shift}`, `{/ctrl}`, etc.). If
> they are not closed explicitly, then events will be fired to close them
> automatically (to disable this, set the `skipAutoClose` option to `true`).
<!-- space out these notes -->

Expand Down Expand Up @@ -308,16 +317,17 @@ userEvent.selectOptions(screen.getByTestId('select-multiple'), [
])
```

### `toggleSelectOptions(element, values)`
### `deselectOptions(element, values)`

Toggle the specified option(s) of a `<select multiple>` element.
Remove the selection for the specified option(s) of a `<select multiple>`
element.

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

test('toggleSelectOptions', () => {
test('deselectOptions', () => {
render(
<select multiple>
<option value="1">A</option>
Expand All @@ -326,14 +336,12 @@ test('toggleSelectOptions', () => {
</select>,
)

userEvent.toggleSelectOptions(screen.getByRole('listbox'), ['1', '3'])

expect(screen.getByText('A').selected).toBe(true)
expect(screen.getByText('C').selected).toBe(true)

userEvent.toggleSelectOptions(screen.getByRole('listbox'), ['1'])

expect(screen.getByText('A').selected).toBe(false)
userEvent.selectOptions(screen.getByRole('listbox'), '2')
expect(screen.getByText('B').selected).toBe(true)
userEvent.deselectOptions(screen.getByRole('listbox'), '2')
expect(screen.getByText('B').selected).toBe(false)
// can do multiple at once as well:
// userEvent.deselectOptions(screen.getByRole('listbox'), ['1', '2'])
})
```

Expand Down Expand Up @@ -397,7 +405,7 @@ it('should cycle elements in document tab order', () => {
})
```

### `async hover(element)`
### `hover(element)`

Hovers over `element`.

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

test('hover', async () => {
test('hover', () => {
const messageText = 'Hello'
render(
<Tooltip messageText={messageText}>
<TrashIcon aria-label="Delete" />
</Tooltip>,
)

await userEvent.hover(screen.getByLabelText(/delete/i))
userEvent.hover(screen.getByLabelText(/delete/i))
expect(screen.getByText(messageText)).toBeInTheDocument()
await userEvent.unhover(screen.getByLabelText(/delete/i))
userEvent.unhover(screen.getByLabelText(/delete/i))
expect(screen.queryByText(messageText)).not.toBeInTheDocument()
})
```

### `async unhover(element)`
### `unhover(element)`

Unhovers out of `element`.

> See [above](#async-hoverelement) for an example
> See [above](#hoverelement) for an example
### `paste(element, text, eventInit, options)`

Allows you to simulate the user pasting some text into an input.

```javascript
test('should paste text in input', () => {
render(<MyInput />)

const text = 'Hello, world!'
userEvent.paste(getByRole('textbox', {name: /paste your greeting/i}), text)
expect(element).toHaveValue(text)
})
```

You can use the `eventInit` if what you're pasting should have `clipboardData`
(like `files`).

## Issues

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

<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the [all-contributors][all-contributors] specification.
Expand Down
8 changes: 0 additions & 8 deletions jest.config.js
Expand Up @@ -3,12 +3,4 @@ const config = require('kcd-scripts/jest')
module.exports = {
...config,
testEnvironment: 'jest-environment-jsdom',
coverageThreshold: {
global: {
branches: 93,
functions: 93,
lines: 93,
statements: 93,
},
},
}
5 changes: 3 additions & 2 deletions package.json
Expand Up @@ -41,9 +41,10 @@
"@babel/runtime": "^7.10.2"
},
"devDependencies": {
"@testing-library/dom": "^7.9.0",
"@testing-library/jest-dom": "^5.9.0",
"@testing-library/dom": "^7.16.0",
"@testing-library/jest-dom": "^5.10.1",
"is-ci": "^2.0.0",
"jest-serializer-ansi": "^1.0.3",
"kcd-scripts": "^6.2.3"
},
"peerDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions src/.eslintrc
@@ -0,0 +1,8 @@
{
"rules": {
// everything in this directory is intentionally running in series, not parallel
// because user's cannot fire multiple events at the same time and we need
// all events fired in a predictable order.
"no-await-in-loop": "off"
}
}
50 changes: 50 additions & 0 deletions src/__mocks__/@testing-library/dom.js
@@ -0,0 +1,50 @@
// this helps us track what the state is before and after an event is fired
// this is needed for determining the snapshot values
const actual = jest.requireActual('@testing-library/dom')

function getTrackedElementValues(element) {
return {
value: element.value,
checked: element.checked,
selectionStart: element.selectionStart,
selectionEnd: element.selectionEnd,

// unfortunately, changing a select option doesn't happen within fireEvent
// but rather imperatively via `options.selected = newValue`
// because of this we don't (currently) have a way to track before/after
// in a given fireEvent call.
}
}

function wrapWithTestData(fn) {
return (element, init) => {
const before = getTrackedElementValues(element)
const testData = {before}

// put it on the element so the event handler can grab it
element.testData = testData
const result = fn(element, init)

const after = getTrackedElementValues(element)
Object.assign(testData, {after})

// elete the testData for the next event
delete element.testData
return result
}
}

const mockFireEvent = wrapWithTestData(actual.fireEvent)

for (const key of Object.keys(actual.fireEvent)) {
if (typeof actual.fireEvent[key] === 'function') {
mockFireEvent[key] = wrapWithTestData(actual.fireEvent[key], key)
} else {
mockFireEvent[key] = actual.fireEvent[key]
}
}

module.exports = {
...actual,
fireEvent: mockFireEvent,
}
66 changes: 66 additions & 0 deletions src/__tests__/blur.js
@@ -0,0 +1,66 @@
import {blur} from '../blur'
import {focus} from '../focus'
import {setup} from './helpers/utils'

test('blur a button', () => {
const {element, getEventSnapshot, clearEventCalls} = setup(`<button />`)
focus(element)
clearEventCalls()
blur(element)
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: button
button - blur
button - focusout
`)
expect(element).not.toHaveFocus()
})

test('no events fired on an unblurable input', () => {
const {element, getEventSnapshot, clearEventCalls} = setup(`<div />`)
focus(element)
clearEventCalls()
blur(element)
expect(getEventSnapshot()).toMatchInlineSnapshot(
`No events were fired on: div`,
)
expect(element).not.toHaveFocus()
})

test('blur with tabindex', () => {
const {element, getEventSnapshot, clearEventCalls} = setup(
`<div tabindex="0" />`,
)
focus(element)
clearEventCalls()
blur(element)
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: div
div - blur
div - focusout
`)
expect(element).not.toHaveFocus()
})

test('no events fired on a disabled blurable input', () => {
const {element, getEventSnapshot, clearEventCalls} = setup(
`<button disabled />`,
)
focus(element)
clearEventCalls()
blur(element)
expect(getEventSnapshot()).toMatchInlineSnapshot(
`No events were fired on: button`,
)
expect(element).not.toHaveFocus()
})

test('no events fired if the element is not focused', () => {
const {element, getEventSnapshot} = setup(`<button />`)
blur(element)
expect(getEventSnapshot()).toMatchInlineSnapshot(
`No events were fired on: button`,
)
expect(element).not.toHaveFocus()
})

0 comments on commit e72205a

Please sign in to comment.