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_ + +- +- + + + + +

Code is Poetry.

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( +
+ parent +
child
+
+ ); + 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( +
+ form + + +
+ ); + 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( +
+ form + + + +
+ ); + 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( +
+ form + + + + +
+ ); + 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;