diff --git a/bin/api-docs/packages.js b/bin/api-docs/packages.js
index 71c2bed2bb14b1..d118464d28308d 100644
--- a/bin/api-docs/packages.js
+++ b/bin/api-docs/packages.js
@@ -33,6 +33,7 @@ const packages = [
'redux-routine',
'rich-text',
'shortcode',
+ 'test-utils',
'url',
'viewport',
'warning',
diff --git a/docs/manifest-devhub.json b/docs/manifest-devhub.json
index 673f97e93c74ac..b7b30d4f8c7444 100644
--- a/docs/manifest-devhub.json
+++ b/docs/manifest-devhub.json
@@ -1469,6 +1469,12 @@
"markdown_source": "../packages/shortcode/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/test-utils",
+ "slug": "packages-test-utils",
+ "markdown_source": "../packages/test-utils/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/token-list",
"slug": "packages-token-list",
diff --git a/package-lock.json b/package-lock.json
index caa337f6af5ef3..4db8a7d63c6b26 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6690,6 +6690,12 @@
"any-observable": "^0.3.0"
}
},
+ "@sheerun/mutationobserver-shim": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz",
+ "integrity": "sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==",
+ "dev": true
+ },
"@storybook/addon-a11y": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-5.3.2.tgz",
@@ -9485,6 +9491,115 @@
"resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.0.2.tgz",
"integrity": "sha512-Nggtk7/ljfNPpAX8CjxxLkMKuO6u2gH1ozmTvGclWF2pNcxTf6YGghYNYNWZRKrimXGhQ8yZqvAHep7h80K04g=="
},
+ "@testing-library/dom": {
+ "version": "6.12.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.12.2.tgz",
+ "integrity": "sha512-KCnvHra5fV+wDxg3wJObGvZFxq7v1DJt829GNFLuRDjKxVNc/B5AdsylNF5PMHFbWMXDsHwM26d2NZcZO9KjbQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.6.2",
+ "@sheerun/mutationobserver-shim": "^0.3.2",
+ "@types/testing-library__dom": "^6.0.0",
+ "aria-query": "3.0.0",
+ "pretty-format": "^24.9.0",
+ "wait-for-expect": "^3.0.0"
+ },
+ "dependencies": {
+ "@jest/types": {
+ "version": "24.9.0",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz",
+ "integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^1.1.1",
+ "@types/yargs": "^13.0.0"
+ }
+ },
+ "@types/yargs": {
+ "version": "13.0.8",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.8.tgz",
+ "integrity": "sha512-XAvHLwG7UQ+8M4caKIH0ZozIOYay5fQkAgyIXegXT9jPtdIGdhga+sUEdAr1CiG46aB+c64xQEYyEzlwWVTNzA==",
+ "dev": true,
+ "requires": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+ "dev": true
+ },
+ "pretty-format": {
+ "version": "24.9.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz",
+ "integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==",
+ "dev": true,
+ "requires": {
+ "@jest/types": "^24.9.0",
+ "ansi-regex": "^4.0.0",
+ "ansi-styles": "^3.2.0",
+ "react-is": "^16.8.4"
+ }
+ }
+ }
+ },
+ "@testing-library/jest-dom": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-4.2.4.tgz",
+ "integrity": "sha512-j31Bn0rQo12fhCWOUWy9fl7wtqkp7In/YP2p5ZFyRuiiB9Qs3g+hS4gAmDWONbAHcRmVooNJ5eOHQDCOmUFXHg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.5.1",
+ "chalk": "^2.4.1",
+ "css": "^2.2.3",
+ "css.escape": "^1.5.1",
+ "jest-diff": "^24.0.0",
+ "jest-matcher-utils": "^24.0.0",
+ "lodash": "^4.17.11",
+ "pretty-format": "^24.0.0",
+ "redent": "^3.0.0"
+ },
+ "dependencies": {
+ "indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true
+ },
+ "redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "requires": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ }
+ },
+ "strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "requires": {
+ "min-indent": "^1.0.0"
+ }
+ }
+ }
+ },
+ "@testing-library/react": {
+ "version": "9.4.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-9.4.0.tgz",
+ "integrity": "sha512-XdhDWkI4GktUPsz0AYyeQ8M9qS/JFie06kcSnUVcpgOwFjAu9vhwR83qBl+lw9yZWkbECjL8Hd+n5hH6C0oWqg==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.7.6",
+ "@testing-library/dom": "^6.11.0",
+ "@types/testing-library__react": "^9.1.2"
+ }
+ },
"@types/babel-types": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/@types/babel-types/-/babel-types-7.0.7.tgz",
@@ -9689,6 +9804,15 @@
"@types/react": "*"
}
},
+ "@types/react-dom": {
+ "version": "16.9.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.5.tgz",
+ "integrity": "sha512-BX6RQ8s9D+2/gDhxrj8OW+YD4R+8hj7FEM/OJHGNR0KipE1h1mSsf39YeyC81qafkq+N3rU3h3RFbLSwE5VqUg==",
+ "dev": true,
+ "requires": {
+ "@types/react": "*"
+ }
+ },
"@types/react-syntax-highlighter": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.2.tgz",
@@ -9719,6 +9843,25 @@
"integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==",
"dev": true
},
+ "@types/testing-library__dom": {
+ "version": "6.12.0",
+ "resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.12.0.tgz",
+ "integrity": "sha512-PQ/gzABzc53T68RldZ/sJHKCihtP9ofU8XIgOk+H7tlfoCRdg9mqICio5Fo8j3Z8wo+pOfuDsuPprWsn3YtVmA==",
+ "dev": true,
+ "requires": {
+ "pretty-format": "^24.3.0"
+ }
+ },
+ "@types/testing-library__react": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/@types/testing-library__react/-/testing-library__react-9.1.2.tgz",
+ "integrity": "sha512-CYaMqrswQ+cJACy268jsLAw355DZtPZGt3Jwmmotlcu8O/tkoXBI6AeZ84oZBJsIsesozPKzWzmv/0TIU+1E9Q==",
+ "dev": true,
+ "requires": {
+ "@types/react-dom": "*",
+ "@types/testing-library__dom": "*"
+ }
+ },
"@types/unist": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz",
@@ -10593,6 +10736,7 @@
"requires": {
"@jest/reporters": "^24.8.0",
"@wordpress/jest-console": "file:packages/jest-console",
+ "@wordpress/test-utils": "file:packages/test-utils",
"babel-jest": "^24.9.0",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.10.0",
@@ -10812,6 +10956,16 @@
"memize": "^1.0.5"
}
},
+ "@wordpress/test-utils": {
+ "version": "file:packages/test-utils",
+ "dev": true,
+ "requires": {
+ "@testing-library/dom": "^6.10.1",
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@wordpress/dom": "file:packages/dom"
+ }
+ },
"@wordpress/token-list": {
"version": "file:packages/token-list",
"requires": {
@@ -15902,6 +16056,12 @@
"integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=",
"dev": true
},
+ "css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
+ "dev": true
+ },
"cssesc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-1.0.1.tgz",
@@ -39397,6 +39557,12 @@
"browser-process-hrtime": "^0.1.2"
}
},
+ "wait-for-expect": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-3.0.2.tgz",
+ "integrity": "sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==",
+ "dev": true
+ },
"wait-on": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-3.3.0.tgz",
diff --git a/package.json b/package.json
index 9d5c523ad48278..02fc31b4359cbf 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"@wordpress/npm-package-json-lint-config": "file:packages/npm-package-json-lint-config",
"@wordpress/postcss-themes": "file:packages/postcss-themes",
"@wordpress/scripts": "file:packages/scripts",
+ "@wordpress/test-utils": "file:packages/test-utils",
"babel-loader": "8.0.6",
"babel-plugin-emotion": "10.0.27",
"babel-plugin-inline-json-import": "0.3.2",
diff --git a/packages/jest-preset-default/CHANGELOG.md b/packages/jest-preset-default/CHANGELOG.md
index 6113ac0aba4d7c..fd7b183921adcc 100644
--- a/packages/jest-preset-default/CHANGELOG.md
+++ b/packages/jest-preset-default/CHANGELOG.md
@@ -1,5 +1,9 @@
## Master
+### New Features
+
+- Add `@wordpress/test-utils/extend-expect` to Jest setup ([18855](https://github.com/WordPress/gutenberg/pull/18855)).
+
## 5.4.0 (2020-02-04)
### Bug Fixes
diff --git a/packages/jest-preset-default/jest-preset.js b/packages/jest-preset-default/jest-preset.js
index fed6f89aa9c42d..b4e08b82b28f5f 100644
--- a/packages/jest-preset-default/jest-preset.js
+++ b/packages/jest-preset-default/jest-preset.js
@@ -14,6 +14,7 @@ module.exports = {
require.resolve(
'@wordpress/jest-preset-default/scripts/setup-test-framework.js'
),
+ require.resolve( '@wordpress/test-utils/extend-expect' ),
],
snapshotSerializers: [ require.resolve( 'enzyme-to-json/serializer.js' ) ],
testMatch: [
diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json
index f21666ff2f55a6..10c53221aba984 100644
--- a/packages/jest-preset-default/package.json
+++ b/packages/jest-preset-default/package.json
@@ -33,6 +33,7 @@
"dependencies": {
"@jest/reporters": "^24.8.0",
"@wordpress/jest-console": "file:../jest-console",
+ "@wordpress/test-utils": "file:../test-utils",
"babel-jest": "^24.9.0",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.10.0",
diff --git a/packages/test-utils/.npmrc b/packages/test-utils/.npmrc
new file mode 100644
index 00000000000000..43c97e719a5a82
--- /dev/null
+++ b/packages/test-utils/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md
new file mode 100644
index 00000000000000..3d4899c114aec7
--- /dev/null
+++ b/packages/test-utils/CHANGELOG.md
@@ -0,0 +1 @@
+## master
diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md
new file mode 100644
index 00000000000000..c6ac6367473d40
--- /dev/null
+++ b/packages/test-utils/README.md
@@ -0,0 +1,112 @@
+# Test Utils
+
+Integration test utils for WordPress.
+
+## Installation
+
+Install the module
+
+```bash
+npm install @wordpress/test-utils --save-dev
+```
+
+## API
+
+
+
+# **act**
+
+Simply calls ReactDOMTestUtils.act(cb) If that's not available (older version of react)
+then it simply calls the given callback immediately.
+
+_Related_
+
+-
+-
+
+# **blur**
+
+Blur element.
+
+_Parameters_
+
+- _element_ `[Element]`:
+
+# **click**
+
+Click element.
+
+_Parameters_
+
+- _element_ `Element`:
+- _options_ `[Object]`:
+
+# **fireEvent**
+
+Convenience methods for firing DOM events.
+
+_Related_
+
+-
+
+# **focus**
+
+Focus element.
+
+_Parameters_
+
+- _element_ `Element`:
+
+# **hover**
+
+Hover element.
+
+_Parameters_
+
+- _element_ `Element`:
+- _options_ `[Object]`:
+
+# **press**
+
+Press element.
+
+_Parameters_
+
+- _key_ `string`:
+- _element_ `[Element]`:
+- _options_ `[Object]`:
+
+# **render**
+
+Render into a container which is appended to `document.body`.
+
+_Related_
+
+-
+
+# **type**
+
+Type on a text field element.
+
+_Parameters_
+
+- _text_ `string`:
+- _element_ `[Element]`:
+- _options_ `[Object]`:
+
+# **wait**
+
+When in need to wait for non-deterministic periods of time you can use `wait`
+to wait for your expectations to pass. The `wait` function is a small
+wrapper around the [`wait-for-expect`](https://github.com/TheBrainFamily/wait-for-expect)
+module.
+
+_Related_
+
+-
+-
+
+
+
+
+
diff --git a/packages/test-utils/extend-expect.js b/packages/test-utils/extend-expect.js
new file mode 100644
index 00000000000000..c904e552623097
--- /dev/null
+++ b/packages/test-utils/extend-expect.js
@@ -0,0 +1,4 @@
+/**
+ * External dependencies
+ */
+require( '@testing-library/jest-dom/extend-expect' );
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
new file mode 100644
index 00000000000000..036c447c01eff2
--- /dev/null
+++ b/packages/test-utils/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@wordpress/test-utils",
+ "version": "0.0.1",
+ "description": "Integration test utils for WordPress.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "test",
+ "utils"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/test-utils/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/test-utils"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "files": [
+ "build",
+ "build-module",
+ "extend-expect"
+ ],
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "dependencies": {
+ "@testing-library/dom": "^6.10.1",
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@wordpress/dom": "file:../dom"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0",
+ "react-dom": "^16.8.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/test-utils/src/act.js b/packages/test-utils/src/act.js
new file mode 100644
index 00000000000000..3343fc952b12dd
--- /dev/null
+++ b/packages/test-utils/src/act.js
@@ -0,0 +1,13 @@
+/**
+ * External dependencies
+ */
+import { act } from '@testing-library/react';
+
+/**
+ * Simply calls ReactDOMTestUtils.act(cb) If that's not available (older version of react)
+ * then it simply calls the given callback immediately.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api#act
+ * @see https://reactjs.org/docs/test-utils.html#act
+ */
+export default act;
diff --git a/packages/test-utils/src/blur.js b/packages/test-utils/src/blur.js
new file mode 100644
index 00000000000000..7ccf5625cb2204
--- /dev/null
+++ b/packages/test-utils/src/blur.js
@@ -0,0 +1,38 @@
+/**
+ * Internal dependencies
+ */
+import fireEvent from './fire-event';
+import act from './act';
+import getActiveElement from './utils/get-active-element';
+import isBodyElement from './utils/is-body-element';
+
+/**
+ * Blur element.
+ *
+ * @param {Element} [element]
+ */
+export default function blur( element ) {
+ if ( ! element ) {
+ element = getActiveElement();
+ }
+
+ if (
+ ! element ||
+ isBodyElement( element ) ||
+ getActiveElement( element ) !== element
+ ) {
+ return;
+ }
+
+ // This is set by `type` so `blur` knows whether to dispatch a change event
+ if ( element.dirty ) {
+ fireEvent.change( element );
+ element.dirty = false;
+ }
+
+ act( () => {
+ element.blur();
+ } );
+
+ fireEvent.focusOut( element );
+}
diff --git a/packages/test-utils/src/click.js b/packages/test-utils/src/click.js
new file mode 100644
index 00000000000000..dce7967da458c3
--- /dev/null
+++ b/packages/test-utils/src/click.js
@@ -0,0 +1,201 @@
+/**
+ * Internal dependencies
+ */
+import fireEvent from './fire-event';
+import focus from './focus';
+import hover from './hover';
+import blur from './blur';
+import isFocusable from './utils/is-focusable';
+import subscribeDefaultPrevented from './utils/subscribe-default-prevented';
+import getClosestFocusable from './utils/get-closest-focusable';
+
+/**
+ * @param {Element} element
+ * @return {HTMLLabelElement} Closest label element
+ */
+function getClosestLabel( element ) {
+ if ( ! isFocusable( element ) ) {
+ return element.closest( 'label' );
+ }
+ return null;
+}
+
+/**
+ * @param {HTMLLabelElement} element
+ * @return {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} Input element
+ */
+function getInputFromLabel( element ) {
+ const input = element.htmlFor
+ ? element.ownerDocument.getElementById( element.htmlFor )
+ : element.querySelector( 'input,textarea,select' );
+ return input;
+}
+
+/**
+ *
+ * @param {HTMLLabelElement} element
+ * @param {Object} defaultPrevented
+ * @param {Object} options
+ */
+function clickLabel( element, defaultPrevented, options ) {
+ const input = getInputFromLabel( element );
+ const isInputDisabled = Boolean( input && input.disabled );
+
+ if ( input ) {
+ // JSDOM will automatically "click" input right after we "click" the label.
+ // Since we need to "focus" it first, we temporarily disable it so it won't
+ // get automatically clicked.
+ input.disabled = true;
+ }
+
+ fireEvent.click( element, options );
+
+ if ( input ) {
+ // Now we can revert input disabled state and fire events on it in the
+ // right order.
+ input.disabled = isInputDisabled;
+ if ( ! defaultPrevented.current && isFocusable( input ) ) {
+ focus( input );
+ // Only "click" is fired! Browsers don't go over the whole event stack in
+ // this case (mousedown, mouseup etc.).
+ fireEvent.click( input );
+ }
+ }
+}
+
+/**
+ * @param {HTMLOptionElement} element
+ * @param {boolean} selected
+ */
+function setSelected( element, selected ) {
+ element.setAttribute( 'selected', selected ? 'selected' : '' );
+ element.selected = selected;
+}
+
+/**
+ * @param {HTMLOptionElement} element
+ * @param {Object} eventOptions
+ */
+function clickOption( element, eventOptions ) {
+ const select = element.closest( 'select' );
+
+ if ( ! select ) {
+ fireEvent.click( element, eventOptions );
+ return;
+ }
+
+ if ( select.multiple ) {
+ const options = Array.from( select.options );
+
+ const resetOptions = () =>
+ options.forEach( ( option ) => {
+ setSelected( option, false );
+ } );
+
+ const selectRange = ( a, b ) => {
+ const from = Math.min( a, b );
+ const to = Math.max( a, b ) + 1;
+ const selectedOptions = options.slice( from, to );
+ selectedOptions.forEach( ( option ) => {
+ setSelected( option, true );
+ } );
+ };
+
+ if ( eventOptions.shiftKey ) {
+ const elementIndex = options.indexOf( element );
+ // https://stackoverflow.com/a/16530782/5513909
+ const referenceOption = select.lastOptionSelectedNotByShiftKey;
+ const referenceOptionIndex = referenceOption
+ ? options.indexOf( referenceOption )
+ : -1;
+
+ resetOptions();
+ // Select options between the reference option and the clicked element
+ selectRange( elementIndex, referenceOptionIndex );
+ setSelected( element, true );
+ } else {
+ // Keep track of this option as this will be used later when shift key
+ // is used.
+ select.lastOptionSelectedNotByShiftKey = element;
+
+ if ( eventOptions.ctrlKey ) {
+ // Clicking with ctrlKey will select/deselect the option
+ setSelected( element, ! element.selected );
+ } else {
+ // Simply clicking an option will select only that option
+ resetOptions();
+ setSelected( element, true );
+ }
+ }
+ } else {
+ setSelected( element, true );
+ }
+
+ fireEvent.input( select );
+ fireEvent.change( select );
+ fireEvent.click( element, eventOptions );
+}
+
+/**
+ * Click element.
+ *
+ * @param {Element} element
+ * @param {Object} [options]
+ */
+export default function click( element, options = {} ) {
+ hover( element, options );
+ const { disabled } = element;
+
+ let defaultPrevented = subscribeDefaultPrevented(
+ element,
+ 'pointerdown',
+ 'mousedown'
+ );
+
+ fireEvent.pointerDown( element, options );
+
+ if ( ! disabled ) {
+ // Mouse events are not called on disabled elements
+ fireEvent.mouseDown( element, options );
+ }
+
+ if ( ! defaultPrevented.current ) {
+ // Do not enter this if event.preventDefault() has been called on
+ // pointerdown or mousedown.
+ if ( isFocusable( element ) ) {
+ focus( element );
+ } else if ( ! disabled ) {
+ // If the element is not focusable, focus the closest focusable parent
+ const closestFocusable = getClosestFocusable( element );
+ if ( closestFocusable ) {
+ focus( closestFocusable );
+ } else {
+ // This will automatically set document.body as the activeElement
+ blur();
+ }
+ }
+ }
+
+ defaultPrevented = subscribeDefaultPrevented( element, 'click' );
+
+ fireEvent.pointerUp( element, options );
+
+ // mouseup and click are not called on disabled elements
+ if ( disabled ) {
+ return;
+ }
+
+ fireEvent.mouseUp( element, options );
+
+ const label = getClosestLabel( element );
+
+ if ( label ) {
+ clickLabel( label, defaultPrevented, options );
+ } else if ( element.tagName === 'OPTION' ) {
+ clickOption( element, options );
+ } else {
+ fireEvent.click( element, options );
+ }
+
+ defaultPrevented.unsubscribe();
+}
diff --git a/packages/test-utils/src/fire-event.js b/packages/test-utils/src/fire-event.js
new file mode 100644
index 00000000000000..821d4475f91e72
--- /dev/null
+++ b/packages/test-utils/src/fire-event.js
@@ -0,0 +1,11 @@
+/**
+ * External dependencies
+ */
+import { fireEvent } from '@testing-library/react';
+
+/**
+ * Convenience methods for firing DOM events.
+ *
+ * @see https://testing-library.com/docs/dom-testing-library/api-events
+ */
+export default fireEvent;
diff --git a/packages/test-utils/src/focus.js b/packages/test-utils/src/focus.js
new file mode 100644
index 00000000000000..87c10e79bd842c
--- /dev/null
+++ b/packages/test-utils/src/focus.js
@@ -0,0 +1,27 @@
+/**
+ * Internal dependencies
+ */
+import fireEvent from './fire-event';
+import act from './act';
+import blur from './blur';
+import isFocusable from './utils/is-focusable';
+import getActiveElement from './utils/get-active-element';
+
+/**
+ * Focus element.
+ *
+ * @param {Element} element
+ */
+export default function focus( element ) {
+ if ( getActiveElement( element ) === element || ! isFocusable( element ) ) {
+ return;
+ }
+
+ blur();
+
+ act( () => {
+ element.focus();
+ } );
+
+ fireEvent.focusIn( element );
+}
diff --git a/packages/test-utils/src/hover.js b/packages/test-utils/src/hover.js
new file mode 100644
index 00000000000000..073e34fa0e736d
--- /dev/null
+++ b/packages/test-utils/src/hover.js
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import { fireEvent as domFireEvent } from '@testing-library/dom';
+
+/**
+ * Internal dependencies
+ */
+import fireEvent from './fire-event';
+import act from './act';
+import getDocument from './utils/get-document';
+
+/**
+ * Hover element.
+ *
+ * @param {Element} element
+ * @param {Object} [options]
+ */
+export default function hover( element, options ) {
+ const document = getDocument( element );
+ const { lastHovered } = document;
+ const { disabled } = element;
+
+ if ( lastHovered ) {
+ fireEvent.pointerMove( lastHovered, options );
+ fireEvent.mouseMove( lastHovered, options );
+
+ const isElementWithinLastHovered = lastHovered.contains( element );
+
+ fireEvent.pointerOut( lastHovered, options );
+
+ if ( ! isElementWithinLastHovered ) {
+ fireEvent.pointerLeave( lastHovered, options );
+ }
+
+ fireEvent.mouseOut( lastHovered, options );
+
+ if ( ! isElementWithinLastHovered ) {
+ act( () => {
+ // fireEvent.mouseLeave would be the same as fireEvent.mouseOut
+ domFireEvent.mouseLeave( lastHovered, options );
+ } );
+ }
+ }
+
+ fireEvent.pointerOver( element, options );
+ fireEvent.pointerEnter( element, options );
+
+ if ( ! disabled ) {
+ fireEvent.mouseOver( element, options );
+ act( () => {
+ // fireEvent.mouseEnter would be the same as fireEvent.mouseOver
+ domFireEvent.mouseEnter( element, options );
+ } );
+ }
+
+ fireEvent.pointerMove( element, options );
+
+ if ( ! disabled ) {
+ fireEvent.mouseMove( element, options );
+ }
+
+ document.lastHovered = element;
+}
diff --git a/packages/test-utils/src/index.js b/packages/test-utils/src/index.js
new file mode 100644
index 00000000000000..9bd9ad6ffa18c4
--- /dev/null
+++ b/packages/test-utils/src/index.js
@@ -0,0 +1,10 @@
+export { default as act } from './act';
+export { default as blur } from './blur';
+export { default as click } from './click';
+export { default as fireEvent } from './fire-event';
+export { default as focus } from './focus';
+export { default as hover } from './hover';
+export { default as press } from './press';
+export { default as render } from './render';
+export { default as type } from './type';
+export { default as wait } from './wait';
diff --git a/packages/test-utils/src/press.js b/packages/test-utils/src/press.js
new file mode 100644
index 00000000000000..d54e695b1bd0ab
--- /dev/null
+++ b/packages/test-utils/src/press.js
@@ -0,0 +1,202 @@
+/**
+ * Internal dependencies
+ */
+import fireEvent from './fire-event';
+import focus from './focus';
+import blur from './blur';
+import getDocument from './utils/get-document';
+import getActiveElement from './utils/get-active-element';
+import isBodyElement from './utils/is-body-element';
+import isFocusable from './utils/is-focusable';
+import getPreviousTabbable from './utils/get-previous-tabbable';
+import getNextTabbable from './utils/get-next-tabbable';
+import subscribeDefaultPrevented from './utils/subscribe-default-prevented';
+
+const clickableInputTypes = [
+ 'button',
+ 'color',
+ 'file',
+ 'image',
+ 'reset',
+ 'submit',
+];
+
+/**
+ * @param {HTMLInputElement} element
+ * @param {Object} options
+ */
+function submitFormByPressingEnterOn( element, options ) {
+ const { form } = element;
+
+ if ( ! form ) {
+ return;
+ }
+
+ const elements = Array.from( form.elements );
+
+ // When pressing enter on an input, the form is submitted only when there is
+ // only one of these input types present (or there's a submit button).
+ const validTypes = [
+ 'email',
+ 'number',
+ 'password',
+ 'search',
+ 'tel',
+ 'text',
+ 'url',
+ ];
+
+ const validInputs = elements.filter(
+ ( el ) => el.tagName === 'INPUT' && validTypes.includes( el.type )
+ );
+
+ const submitButton = elements.find(
+ ( el ) =>
+ [ 'INPUT', 'BUTTON' ].includes( el.tagName ) && el.type === 'submit'
+ );
+
+ if ( validInputs.length === 1 || submitButton ) {
+ fireEvent.submit( form, options );
+ }
+}
+
+const keyDownMap = {
+ Tab( element, { shiftKey } ) {
+ const { body } = getDocument( element );
+ const nextElement = shiftKey
+ ? getPreviousTabbable( body )
+ : getNextTabbable( body );
+ if ( nextElement ) {
+ focus( nextElement );
+ }
+ },
+
+ Enter( element, options ) {
+ const nonSubmittableTypes = [ ...clickableInputTypes, 'hidden' ];
+
+ const isClickable =
+ element.tagName === 'BUTTON' ||
+ ( element.tagName === 'INPUT' &&
+ clickableInputTypes.includes( element.type ) );
+
+ const isSubmittable =
+ element.tagName === 'INPUT' &&
+ ! nonSubmittableTypes.includes( element.type );
+
+ if ( isClickable ) {
+ fireEvent.click( element, options );
+ } else if ( isSubmittable ) {
+ submitFormByPressingEnterOn( element, options );
+ }
+ },
+};
+
+const keyUpMap = {
+ // Space
+ ' ': ( element, options ) => {
+ const spaceableTypes = [ ...clickableInputTypes, 'checkbox', 'radio' ];
+
+ const isSpaceable =
+ element.tagName === 'BUTTON' ||
+ ( element.tagName === 'INPUT' &&
+ spaceableTypes.includes( element.type ) );
+
+ if ( isSpaceable ) {
+ fireEvent.click( element, options );
+ }
+ },
+};
+
+/**
+ * Press element.
+ *
+ * @param {string} key
+ * @param {Element} [element]
+ * @param {Object} [options]
+ */
+export default function press( key, element, options = {} ) {
+ const document = getDocument( element );
+
+ // eslint-disable-next-line eqeqeq
+ if ( ! element ) {
+ element = getActiveElement() || document.body;
+ }
+
+ if ( ! element ) {
+ return;
+ }
+
+ // We can't press on elements that aren't focusable
+ if ( ! isFocusable( element ) && ! isBodyElement( element ) ) {
+ return;
+ }
+
+ // If element is not focused, we should focus it
+ if ( getActiveElement( element ) !== element ) {
+ if ( isBodyElement( element ) ) {
+ blur();
+ } else {
+ focus( element );
+ }
+ }
+
+ // Track event.preventDefault() calls so we bail out of keydown/keyup effects
+ const defaultPrevented = subscribeDefaultPrevented(
+ element,
+ 'keydown',
+ 'keyup'
+ );
+
+ fireEvent.keyDown( element, { key, ...options } );
+
+ if (
+ ! defaultPrevented.current &&
+ key in keyDownMap &&
+ ! options.metaKey
+ ) {
+ keyDownMap[ key ]( element, options );
+ }
+
+ // If keydown effect changed focus (e.g. Tab), keyup will be triggered on the
+ // next element.
+ if ( getActiveElement( element ) !== element ) {
+ element = getActiveElement( element );
+ }
+
+ fireEvent.keyUp( element, { key, ...options } );
+
+ if ( ! defaultPrevented.current && key in keyUpMap && ! options.metaKey ) {
+ keyUpMap[ key ]( element, options );
+ }
+
+ defaultPrevented.unsubscribe();
+}
+
+/**
+ * @callback Press
+ * @param {Element} [element]
+ * @param {Object} [options]
+ */
+/**
+ * @param {string} key
+ * @param {Object} [defaultOptions]
+ * @return {Press} Press function
+ */
+function createPress( key, defaultOptions = {} ) {
+ return ( element, options = {} ) =>
+ press( key, element, { ...defaultOptions, ...options } );
+}
+
+press.Escape = createPress( 'Escape' );
+press.Tab = createPress( 'Tab' );
+press.ShiftTab = createPress( 'Tab', { shiftKey: true } );
+press.Enter = createPress( 'Enter' );
+press.Space = createPress( ' ' );
+press.ArrowUp = createPress( 'ArrowUp' );
+press.ArrowRight = createPress( 'ArrowRight' );
+press.ArrowDown = createPress( 'ArrowDown' );
+press.ArrowLeft = createPress( 'ArrowLeft' );
+press.End = createPress( 'End' );
+press.Home = createPress( 'Home' );
+press.PageUp = createPress( 'PageUp' );
+press.PageDown = createPress( 'PageDown' );
diff --git a/packages/test-utils/src/render.js b/packages/test-utils/src/render.js
new file mode 100644
index 00000000000000..e4ccceb244640b
--- /dev/null
+++ b/packages/test-utils/src/render.js
@@ -0,0 +1,11 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * Render into a container which is appended to `document.body`.
+ *
+ * @see https://testing-library.com/docs/react-testing-library/api#render
+ */
+export default render;
diff --git a/packages/test-utils/src/test/blur.js b/packages/test-utils/src/test/blur.js
new file mode 100644
index 00000000000000..9f343e31b2125d
--- /dev/null
+++ b/packages/test-utils/src/test/blur.js
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import render from '../render';
+import blur from '../blur';
+
+describe( 'blur', () => {
+ it( 'should blur focused button', async () => {
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ const { getByText } = render( );
+ const button = getByText( 'button' );
+
+ expect( button ).toHaveFocus();
+ blur( button );
+ expect( document.body ).toHaveFocus();
+ } );
+} );
diff --git a/packages/test-utils/src/test/click.js b/packages/test-utils/src/test/click.js
new file mode 100644
index 00000000000000..97c6dbe55e3cfe
--- /dev/null
+++ b/packages/test-utils/src/test/click.js
@@ -0,0 +1,241 @@
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import render from '../render';
+import click from '../click';
+
+describe( 'click', () => {
+ it( 'should focus button on click', () => {
+ const { getByText } = render( );
+ const button = getByText( 'button' );
+ click( button );
+ expect( button ).toHaveFocus();
+ } );
+
+ it( 'should not focus button on click when event.preventDefault() was called on mouse down', () => {
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ click( button );
+ expect( button ).not.toHaveFocus();
+ } );
+
+ it( 'should not focus disabled button on click', () => {
+ const onClick = jest.fn();
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ click( button );
+ expect( button ).not.toHaveFocus();
+ expect( onClick ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should focus closest focusable parent', () => {
+ const { getByText, baseElement } = render(
+
+ );
+ const parent = getByText( 'parent' );
+ const child = getByText( 'child' );
+
+ expect( baseElement ).toHaveFocus();
+ click( child );
+ expect( parent ).toHaveFocus();
+ } );
+
+ it( 'should not focus focusable parent if child is disabled', () => {
+ const { getByText, baseElement } = render(
+
+ parent
+
+
+ );
+ const child = getByText( 'child' );
+
+ expect( baseElement ).toHaveFocus();
+ click( child );
+ expect( baseElement ).toHaveFocus();
+ } );
+
+ it( 'should focus input when clicking on label', () => {
+ const { getByText, getByLabelText, baseElement } = render(
+ <>
+
+ { /* eslint-disable-next-line no-restricted-syntax */ }
+
+ { /* eslint-disable-next-line jsx-a11y/label-has-for */ }
+
+ >
+ );
+ const label1 = getByText( 'input1' );
+ const input1 = getByLabelText( 'input1' );
+ const label2 = getByText( 'input2' );
+ const input2 = getByLabelText( 'input2' );
+
+ expect( baseElement ).toHaveFocus();
+ click( label1 );
+ expect( input1 ).toHaveFocus();
+ click( label2 );
+ expect( input2 ).toHaveFocus();
+ } );
+
+ it( 'should not focus disabled input when clicking on label', () => {
+ const { getByText, baseElement } = render(
+ <>
+
+ { /* eslint-disable-next-line no-restricted-syntax */ }
+
+ { /* eslint-disable-next-line jsx-a11y/label-has-for */ }
+
+ >
+ );
+ const label1 = getByText( 'input1' );
+ const label2 = getByText( 'input2' );
+
+ expect( baseElement ).toHaveFocus();
+ click( label1 );
+ expect( baseElement ).toHaveFocus();
+ click( label2 );
+ expect( baseElement ).toHaveFocus();
+ } );
+
+ it( 'should check/uncheck checkbox', () => {
+ const { getByText, getByLabelText } = render(
+ <>
+
+
+ { /* eslint-disable-next-line no-restricted-syntax */ }
+
+ { /* eslint-disable-next-line jsx-a11y/label-has-for */ }
+
+ >
+ );
+ const checkbox1 = getByLabelText( 'checkbox1' );
+ const label2 = getByText( 'checkbox2' );
+ const checkbox2 = getByLabelText( 'checkbox2' );
+ const label3 = getByText( 'checkbox3' );
+ const checkbox3 = getByLabelText( 'checkbox3' );
+
+ expect( checkbox1 ).not.toBeChecked();
+ click( checkbox1 );
+ expect( checkbox1 ).toBeChecked();
+ click( checkbox1 );
+ expect( checkbox1 ).not.toBeChecked();
+
+ expect( checkbox2 ).not.toBeChecked();
+ click( label2 );
+ expect( checkbox2 ).toBeChecked();
+ click( label2 );
+ expect( checkbox2 ).not.toBeChecked();
+
+ expect( checkbox3 ).not.toBeChecked();
+ click( label3 );
+ expect( checkbox3 ).toBeChecked();
+ click( label3 );
+ expect( checkbox3 ).not.toBeChecked();
+ } );
+
+ it( 'should not check/uncheck disabled checkbox', () => {
+ const { getByText, getByLabelText } = render(
+ <>
+
+
+ { /* eslint-disable-next-line no-restricted-syntax */ }
+
+ { /* eslint-disable-next-line jsx-a11y/label-has-for */ }
+
+ >
+ );
+ const checkbox1 = getByLabelText( 'checkbox1' );
+ const label2 = getByText( 'checkbox2' );
+ const checkbox2 = getByLabelText( 'checkbox2' );
+ const label3 = getByText( 'checkbox3' );
+ const checkbox3 = getByLabelText( 'checkbox3' );
+
+ expect( checkbox1 ).not.toBeChecked();
+ click( checkbox1 );
+ expect( checkbox1 ).not.toBeChecked();
+
+ expect( checkbox2 ).not.toBeChecked();
+ click( label2 );
+ expect( checkbox2 ).not.toBeChecked();
+
+ expect( checkbox3 ).not.toBeChecked();
+ click( label3 );
+ expect( checkbox3 ).not.toBeChecked();
+ } );
+
+ it( 'should change select when clicking on options', async () => {
+ const Test = ( { multiple } ) => {
+ return (
+
+ );
+ };
+ const { getByText, getByLabelText, rerender } = render( );
+ const select = getByLabelText( 'select' );
+ const option1 = getByText( 'option1' );
+ const option2 = getByText( 'option2' );
+ const option3 = getByText( 'option3' );
+ const option4 = getByText( 'option4' );
+
+ click( option2 );
+
+ expect( option2.selected ).toBe( true );
+ expect( Array.from( select.selectedOptions ) ).toEqual( [ option2 ] );
+
+ rerender( );
+
+ click( option2 );
+ click( option4, { shiftKey: true } );
+ expect( Array.from( select.selectedOptions ) ).toEqual( [
+ option2,
+ option3,
+ option4,
+ ] );
+
+ click( option3, { ctrlKey: true } );
+ click( option1, { ctrlKey: true } );
+ expect( Array.from( select.selectedOptions ) ).toEqual( [
+ option1,
+ option2,
+ option4,
+ ] );
+
+ click( option3, { shiftKey: true } );
+ expect( Array.from( select.selectedOptions ) ).toEqual( [
+ option1,
+ option2,
+ option3,
+ ] );
+ } );
+} );
diff --git a/packages/test-utils/src/test/focus.js b/packages/test-utils/src/test/focus.js
new file mode 100644
index 00000000000000..219c28facdb7f5
--- /dev/null
+++ b/packages/test-utils/src/test/focus.js
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import render from '../render';
+import focus from '../focus';
+
+describe( 'focus', () => {
+ it( 'should focus button', () => {
+ const { getByText } = render( );
+ const button = getByText( 'button' );
+ expect( button ).not.toHaveFocus();
+ focus( button );
+ expect( button ).toHaveFocus();
+ } );
+} );
diff --git a/packages/test-utils/src/test/hover.js b/packages/test-utils/src/test/hover.js
new file mode 100644
index 00000000000000..68733651ffbb6e
--- /dev/null
+++ b/packages/test-utils/src/test/hover.js
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import render from '../render';
+import hover from '../hover';
+
+describe( 'hover', () => {
+ it( 'should hover button', () => {
+ const onMouseOut = jest.fn();
+ const onMouseOver = jest.fn();
+ const { getByText } = render(
+ <>
+ { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
+
+
+ >
+ );
+ const button1 = getByText( 'button1' );
+ const button2 = getByText( 'button2' );
+
+ expect( onMouseOut ).not.toHaveBeenCalled();
+ expect( onMouseOver ).not.toHaveBeenCalled();
+
+ hover( button1 );
+
+ expect( onMouseOut ).not.toHaveBeenCalled();
+ expect( onMouseOver ).toHaveBeenCalled();
+
+ hover( button2 );
+
+ expect( onMouseOut ).toHaveBeenCalled();
+ } );
+} );
diff --git a/packages/test-utils/src/test/press.js b/packages/test-utils/src/test/press.js
new file mode 100644
index 00000000000000..b8705c03911be4
--- /dev/null
+++ b/packages/test-utils/src/test/press.js
@@ -0,0 +1,185 @@
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import render from '../render';
+import press from '../press';
+
+describe( 'press', () => {
+ it( 'should focus element when pressing key on it', () => {
+ const { getByText } = render( div
);
+ const div = getByText( 'div' );
+ expect( div ).not.toHaveFocus();
+ press( 'a', div );
+ expect( div ).toHaveFocus();
+ } );
+
+ it( 'should click button when pressing enter', () => {
+ const onClick = jest.fn();
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ expect( onClick ).not.toHaveBeenCalled();
+ press.Enter( button );
+ expect( onClick ).toHaveBeenCalled();
+ } );
+
+ it( 'should not click button when pressing enter if event.preventDefault() was called on key down', () => {
+ const onClick = jest.fn();
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ expect( onClick ).not.toHaveBeenCalled();
+ press.Enter( button );
+ expect( onClick ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should click button when pressing space', () => {
+ const onClick = jest.fn();
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ expect( onClick ).not.toHaveBeenCalled();
+ press.Space( button );
+ expect( onClick ).toHaveBeenCalled();
+ } );
+
+ it( 'should not click button when pressing space if event.preventDefault() was called on key down', () => {
+ const onClick = jest.fn();
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ expect( onClick ).not.toHaveBeenCalled();
+ press.Space( button );
+ expect( onClick ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should not click button when pressing space if event.preventDefault() was called on key up', () => {
+ const onClick = jest.fn();
+ const { getByText } = render(
+
+ );
+ const button = getByText( 'button' );
+ expect( onClick ).not.toHaveBeenCalled();
+ press.Space( button );
+ expect( onClick ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should submit form when pressing enter on form input', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render(
+
+ );
+ const input = getByLabelText( 'input' );
+ expect( onSubmit ).not.toHaveBeenCalled();
+ press.Enter( input );
+ expect( onSubmit ).toHaveBeenCalled();
+ } );
+
+ it( 'should not submit form when pressing enter on form input with multiple text fields', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render(
+
+ );
+ const input = getByLabelText( 'input' );
+ press.Enter( input );
+ expect( onSubmit ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should submit form when pressing enter on form input with multiple text fields with submit button', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render(
+
+ );
+ const input = getByLabelText( 'input' );
+ expect( onSubmit ).not.toHaveBeenCalled();
+ press.Enter( input );
+ expect( onSubmit ).toHaveBeenCalled();
+ } );
+
+ it( 'should move focus when pressing tab', () => {
+ const { getByText } = render(
+ <>
+
+ span
+
+ >
+ );
+ const button1 = getByText( 'button1' );
+ const button2 = getByText( 'button2' );
+
+ expect( button1 ).not.toHaveFocus();
+ press.Tab();
+ expect( button1 ).toHaveFocus();
+ press.Tab();
+ expect( button2 ).toHaveFocus();
+ press.Tab();
+ expect( button1 ).toHaveFocus();
+ press.ShiftTab();
+ expect( button2 ).toHaveFocus();
+ press.ShiftTab();
+ expect( button1 ).toHaveFocus();
+ } );
+
+ it( 'should not move focus when pressing tab if event.preventDefault() was called on key down', () => {
+ const { getByText } = render(
+ <>
+
+ span
+
+ >
+ );
+ const button1 = getByText( 'button1' );
+ const button2 = getByText( 'button2' );
+
+ expect( button1 ).not.toHaveFocus();
+ press.Tab();
+ expect( button1 ).toHaveFocus();
+ press.Tab();
+ expect( button2 ).toHaveFocus();
+ press.Tab();
+ expect( button2 ).toHaveFocus();
+ press.ShiftTab();
+ expect( button2 ).toHaveFocus();
+ } );
+} );
diff --git a/packages/test-utils/src/test/type.js b/packages/test-utils/src/test/type.js
new file mode 100644
index 00000000000000..caa42a10ca81c5
--- /dev/null
+++ b/packages/test-utils/src/test/type.js
@@ -0,0 +1,57 @@
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import render from '../render';
+import type from '../type';
+
+describe( 'type', () => {
+ it( 'should type on input', () => {
+ const values = [];
+ const { getByLabelText } = render(
+ values.push( event.target.value ) }
+ />
+ );
+ const input = getByLabelText( 'input' );
+
+ type( 'ab cd\b\bef', input );
+
+ expect( values ).toEqual( [
+ 'a',
+ 'ab',
+ 'ab ',
+ 'ab c',
+ 'ab cd',
+ 'ab c',
+ 'ab ',
+ 'ab e',
+ 'ab ef',
+ ] );
+ } );
+
+ it( 'should not type on input if event.preventDefault() was called on key down', () => {
+ const values = [];
+ const { getByLabelText } = render(
+ {
+ if ( event.key !== 'e' ) {
+ event.preventDefault();
+ }
+ } }
+ onChange={ ( event ) => values.push( event.target.value ) }
+ />
+ );
+ const input = getByLabelText( 'input' );
+
+ type( 'ab cd\b\befe', input );
+
+ expect( values ).toEqual( [ 'e', 'ee' ] );
+ } );
+} );
diff --git a/packages/test-utils/src/type.js b/packages/test-utils/src/type.js
new file mode 100644
index 00000000000000..8a09a0ca246da3
--- /dev/null
+++ b/packages/test-utils/src/type.js
@@ -0,0 +1,72 @@
+/**
+ * WordPress dependencies
+ */
+import { isTextField } from '@wordpress/dom';
+
+/**
+ * Internal dependencies
+ */
+import fireEvent from './fire-event';
+import focus from './focus';
+import getActiveElement from './utils/get-active-element';
+import isFocusable from './utils/is-focusable';
+import subscribeDefaultPrevented from './utils/subscribe-default-prevented';
+
+const charMap = {
+ '\b': 'Backspace',
+};
+
+const keyMap = {
+ Backspace( element ) {
+ return element.value.substr( 0, element.value.length - 1 );
+ },
+};
+
+/**
+ * Type on a text field element.
+ *
+ * @param {string} text
+ * @param {Element} [element]
+ * @param {Object} [options]
+ */
+export default function type( text, element, options = {} ) {
+ if ( ! element ) {
+ element = getActiveElement();
+ }
+
+ if ( ! element || ! isFocusable( element ) || ! isTextField( element ) ) {
+ return;
+ }
+
+ focus( element );
+
+ // Set element dirty so blur() can dispatch a change event
+ element.dirty = true;
+
+ for ( const char of text ) {
+ const key = char in charMap ? charMap[ char ] : char;
+ const value =
+ key in keyMap
+ ? keyMap[ key ]( element, options )
+ : `${ element.value }${ char }`;
+
+ const defaultPrevented = subscribeDefaultPrevented(
+ element,
+ 'keydown'
+ );
+
+ fireEvent.keyDown( element, { key, ...options } );
+
+ if ( ! defaultPrevented.current && ! element.readOnly ) {
+ fireEvent.input( element, {
+ data: char,
+ target: { value },
+ ...options,
+ } );
+ }
+
+ fireEvent.keyUp( element, { key, ...options } );
+
+ defaultPrevented.unsubscribe();
+ }
+}
diff --git a/packages/test-utils/src/utils/get-active-element.js b/packages/test-utils/src/utils/get-active-element.js
new file mode 100644
index 00000000000000..f5ac7ada59e105
--- /dev/null
+++ b/packages/test-utils/src/utils/get-active-element.js
@@ -0,0 +1,12 @@
+/**
+ * Internal dependencies
+ */
+import getDocument from './get-document';
+
+/**
+ * @param {Element} [element]
+ * @return {Element} Active Element
+ */
+export default function getActiveElement( element ) {
+ return getDocument( element ).activeElement;
+}
diff --git a/packages/test-utils/src/utils/get-closest-focusable.js b/packages/test-utils/src/utils/get-closest-focusable.js
new file mode 100644
index 00000000000000..bfd9472187a99a
--- /dev/null
+++ b/packages/test-utils/src/utils/get-closest-focusable.js
@@ -0,0 +1,18 @@
+/**
+ * Internal dependencies
+ */
+import isFocusable from './is-focusable';
+
+/**
+ * @param {Element} element
+ * @return {Element|null} Closest focusable element
+ */
+export default function getClosestFocusable( element ) {
+ let container = element;
+
+ do {
+ container = container.parentElement;
+ } while ( container && ! isFocusable( container ) );
+
+ return container;
+}
diff --git a/packages/test-utils/src/utils/get-document.js b/packages/test-utils/src/utils/get-document.js
new file mode 100644
index 00000000000000..44de903bfdbdcf
--- /dev/null
+++ b/packages/test-utils/src/utils/get-document.js
@@ -0,0 +1,7 @@
+/**
+ * @param {Element} [element]
+ * @return {Document} Document
+ */
+export default function getDocument( element ) {
+ return element ? element.ownerDocument || window.document : window.document;
+}
diff --git a/packages/test-utils/src/utils/get-next-tabbable.js b/packages/test-utils/src/utils/get-next-tabbable.js
new file mode 100644
index 00000000000000..f77239558a3176
--- /dev/null
+++ b/packages/test-utils/src/utils/get-next-tabbable.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { focus } from '@wordpress/dom';
+
+/**
+ * Internal dependencies
+ */
+import getDocument from './get-document';
+import getActiveElement from './get-active-element';
+import './mock-client-rects';
+
+/**
+ * @param {Element} element
+ * @return {Element} Next tabbable element
+ */
+export default function getNextTabbable( element ) {
+ const tabbableElements = focus.tabbable.find( getDocument( element ) );
+ const currentIndex = tabbableElements.indexOf(
+ getActiveElement( element )
+ );
+ const nextIndex = currentIndex + 1;
+ return nextIndex >= tabbableElements.length
+ ? tabbableElements[ 0 ]
+ : tabbableElements[ nextIndex ];
+}
diff --git a/packages/test-utils/src/utils/get-previous-tabbable.js b/packages/test-utils/src/utils/get-previous-tabbable.js
new file mode 100644
index 00000000000000..d4d2ee416c0fda
--- /dev/null
+++ b/packages/test-utils/src/utils/get-previous-tabbable.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { focus } from '@wordpress/dom';
+/**
+ * Internal dependencies
+ */
+import getDocument from './get-document';
+import getActiveElement from './get-active-element';
+import './mock-client-rects';
+
+/**
+ * @param {Element} element
+ * @return {Element} Previous tabbable element
+ */
+export default function getPreviousTabbable( element ) {
+ const tabbableElements = focus.tabbable.find( getDocument( element ) );
+ const currentIndex = tabbableElements.indexOf(
+ getActiveElement( element )
+ );
+ const previousIndex = currentIndex - 1;
+ return previousIndex < 0
+ ? tabbableElements[ tabbableElements.length - 1 ]
+ : tabbableElements[ previousIndex ];
+}
diff --git a/packages/test-utils/src/utils/is-body-element.js b/packages/test-utils/src/utils/is-body-element.js
new file mode 100644
index 00000000000000..852b544536a379
--- /dev/null
+++ b/packages/test-utils/src/utils/is-body-element.js
@@ -0,0 +1,7 @@
+/**
+ * @param {Element} [element]
+ * @return {boolean} Whether `element` is body
+ */
+export default function isBodyElement( element ) {
+ return element && element.tagName === 'BODY';
+}
diff --git a/packages/test-utils/src/utils/is-focusable.js b/packages/test-utils/src/utils/is-focusable.js
new file mode 100644
index 00000000000000..791b7989308311
--- /dev/null
+++ b/packages/test-utils/src/utils/is-focusable.js
@@ -0,0 +1,21 @@
+/**
+ * WordPress dependencies
+ */
+import { focus } from '@wordpress/dom';
+
+/**
+ * Internal dependencies
+ */
+import './mock-client-rects';
+
+/**
+ * @param {Element} element
+ * @return {boolean} Whether `element` is focusable
+ */
+export default function isFocusable( element ) {
+ if ( ! element.parentElement ) {
+ return false;
+ }
+ const focusableElements = focus.focusable.find( element.parentElement );
+ return focusableElements.indexOf( element ) !== -1;
+}
diff --git a/packages/test-utils/src/utils/mock-client-rects.js b/packages/test-utils/src/utils/mock-client-rects.js
new file mode 100644
index 00000000000000..e18e15999d1dbe
--- /dev/null
+++ b/packages/test-utils/src/utils/mock-client-rects.js
@@ -0,0 +1,25 @@
+function isHidden( element ) {
+ if ( element.parentElement && isHidden( element.parentElement ) ) {
+ return true;
+ }
+ return (
+ ! element.style ||
+ element.style.display === 'none' ||
+ element.style.visibility === 'hidden' ||
+ element.hidden
+ );
+}
+
+function getClientRects() {
+ if ( isHidden( this ) ) {
+ return [];
+ }
+ return [ { width: 1, height: 1 } ];
+}
+
+if (
+ typeof window !== 'undefined' &&
+ window.Element.prototype.getClientRects !== getClientRects
+) {
+ window.Element.prototype.getClientRects = getClientRects;
+}
diff --git a/packages/test-utils/src/utils/subscribe-default-prevented.js b/packages/test-utils/src/utils/subscribe-default-prevented.js
new file mode 100644
index 00000000000000..caad0ed6285bf3
--- /dev/null
+++ b/packages/test-utils/src/utils/subscribe-default-prevented.js
@@ -0,0 +1,35 @@
+/**
+ * @typedef {Object} DefaultPreventedRef
+ * @property {boolean} current
+ * @property {Function} unsubscribe
+ */
+/**
+ * Keep track of event.defaultPrevented state.
+ *
+ * @param {Element} element
+ * @param {...string} eventNames
+ * @return {DefaultPreventedRef} `defaultPrevented` ref object
+ */
+export default function subscribeDefaultPrevented( element, ...eventNames ) {
+ const ref = { current: false, unsubscribe: () => {} };
+
+ const handleEvent = ( event ) => {
+ const preventDefault = event.preventDefault.bind( event );
+ event.preventDefault = () => {
+ ref.current = true;
+ preventDefault();
+ };
+ };
+
+ eventNames.forEach( ( eventName ) => {
+ element.addEventListener( eventName, handleEvent );
+ } );
+
+ ref.unsubscribe = () => {
+ eventNames.forEach( ( eventName ) => {
+ element.removeEventListener( eventName, handleEvent );
+ } );
+ };
+
+ return ref;
+}
diff --git a/packages/test-utils/src/wait.js b/packages/test-utils/src/wait.js
new file mode 100644
index 00000000000000..1ec996a7647e78
--- /dev/null
+++ b/packages/test-utils/src/wait.js
@@ -0,0 +1,15 @@
+/**
+ * External dependencies
+ */
+import { wait } from '@testing-library/react';
+
+/**
+ * When in need to wait for non-deterministic periods of time you can use `wait`
+ * to wait for your expectations to pass. The `wait` function is a small
+ * wrapper around the [`wait-for-expect`](https://github.com/TheBrainFamily/wait-for-expect)
+ * module.
+ *
+ * @see https://testing-library.com/docs/dom-testing-library/api-async#wait
+ * @see https://github.com/TheBrainFamily/wait-for-expect
+ */
+export default wait;