From f251d153d55e43ec84c6012fa09352b251133f20 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 16 Mar 2021 20:14:47 +0100 Subject: [PATCH] feat: add userEvent.keyboard API (#581) BREAKING CHANGE: `userEvent.type` features a rewritten implementation shared with the new `userEvent.keyboard`. This might break code depending on unintended/undocumented behavior of the previous implementation. BREAKING CHANGE: `userEvent.type` treats `{` and `[` as special characters. BREAKING CHANGE: `userEvent.type` returns no Promise if called without `delay`. --- .eslintrc.js | 14 + README.md | 77 +- package.json | 2 + src/__tests__/keyboard/getNextKeyDef.ts | 78 ++ src/__tests__/keyboard/index.ts | 137 +++ src/__tests__/type-modifiers.js | 5 +- src/__tests__/type.js | 20 +- src/__tests__/type/plugin/arrow.ts | 45 + src/__tests__/type/plugin/character.ts | 19 + src/__tests__/type/plugin/control.ts | 48 ++ src/__tests__/type/plugin/functional.ts | 76 ++ src/click.d.ts | 10 + src/index.js | 2 + src/keyboard/getEventProps.ts | 27 + src/keyboard/getNextKeyDef.ts | 135 +++ src/keyboard/index.ts | 75 ++ src/keyboard/keyMap.ts | 79 ++ src/keyboard/keyboardImplementation.ts | 196 +++++ src/keyboard/plugins/arrow.ts | 30 + src/keyboard/plugins/character.ts | 184 ++++ src/keyboard/plugins/control.ts | 45 + .../control/calculateNewDeleteValue.ts | 33 + src/keyboard/plugins/functional.ts | 140 +++ .../functional/calculateBackspaceValue.ts | 43 + src/keyboard/plugins/index.ts | 43 + .../shared/fireChangeForInputTimeIfValid.ts | 12 + src/keyboard/shared/fireInputEventIfNeeded.ts | 63 ++ src/keyboard/shared/index.ts | 3 + src/keyboard/shared/setSelectionRange.ts | 30 + src/keyboard/specialCharMap.ts | 15 + src/keyboard/types.ts | 87 ++ src/keys/navigation-key.js | 71 -- src/type.js | 800 ------------------ src/type/index.ts | 34 + src/type/typeImplementation.ts | 81 ++ src/utils.js | 384 --------- src/utils/click/getMouseEventOptions.ts | 75 ++ src/utils/click/isClickableInput.ts | 18 + src/utils/edit/buildTimeValue.ts | 32 + src/utils/edit/calculateNewValue.ts | 103 +++ src/utils/edit/getSelectionRange.ts | 31 + src/utils/edit/getValue.ts | 12 + src/utils/edit/isContentEditable.ts | 8 + src/utils/edit/isValidDateValue.ts | 8 + src/utils/edit/isValidInputTimeValue.ts | 8 + .../edit/setSelectionRangeIfNecessary.ts | 46 + src/utils/focus/getActiveElement.ts | 21 + src/utils/focus/isFocusable.ts | 9 + src/utils/focus/selector.ts | 10 + src/utils/index.ts | 22 + src/utils/misc/eventWrapper.ts | 9 + src/utils/misc/isDisabled.ts | 5 + src/utils/misc/isInstanceOfElement.ts | 40 + .../isLabelWithInternallyDisabledControl.ts | 16 + src/utils/misc/isVisible.ts | 18 + src/utils/misc/wait.ts | 3 + tsconfig.json | 7 + typings/dom-helpers.d.ts | 3 + typings/index.d.ts | 60 +- typings/test.ts | 4 +- 60 files changed, 2394 insertions(+), 1317 deletions(-) create mode 100644 .eslintrc.js create mode 100644 src/__tests__/keyboard/getNextKeyDef.ts create mode 100644 src/__tests__/keyboard/index.ts create mode 100644 src/__tests__/type/plugin/arrow.ts create mode 100644 src/__tests__/type/plugin/character.ts create mode 100644 src/__tests__/type/plugin/control.ts create mode 100644 src/__tests__/type/plugin/functional.ts create mode 100644 src/click.d.ts create mode 100644 src/keyboard/getEventProps.ts create mode 100644 src/keyboard/getNextKeyDef.ts create mode 100644 src/keyboard/index.ts create mode 100644 src/keyboard/keyMap.ts create mode 100644 src/keyboard/keyboardImplementation.ts create mode 100644 src/keyboard/plugins/arrow.ts create mode 100644 src/keyboard/plugins/character.ts create mode 100644 src/keyboard/plugins/control.ts create mode 100644 src/keyboard/plugins/control/calculateNewDeleteValue.ts create mode 100644 src/keyboard/plugins/functional.ts create mode 100644 src/keyboard/plugins/functional/calculateBackspaceValue.ts create mode 100644 src/keyboard/plugins/index.ts create mode 100644 src/keyboard/shared/fireChangeForInputTimeIfValid.ts create mode 100644 src/keyboard/shared/fireInputEventIfNeeded.ts create mode 100644 src/keyboard/shared/index.ts create mode 100644 src/keyboard/shared/setSelectionRange.ts create mode 100644 src/keyboard/specialCharMap.ts create mode 100644 src/keyboard/types.ts delete mode 100644 src/keys/navigation-key.js delete mode 100644 src/type.js create mode 100644 src/type/index.ts create mode 100644 src/type/typeImplementation.ts delete mode 100644 src/utils.js create mode 100644 src/utils/click/getMouseEventOptions.ts create mode 100644 src/utils/click/isClickableInput.ts create mode 100644 src/utils/edit/buildTimeValue.ts create mode 100644 src/utils/edit/calculateNewValue.ts create mode 100644 src/utils/edit/getSelectionRange.ts create mode 100644 src/utils/edit/getValue.ts create mode 100644 src/utils/edit/isContentEditable.ts create mode 100644 src/utils/edit/isValidDateValue.ts create mode 100644 src/utils/edit/isValidInputTimeValue.ts create mode 100644 src/utils/edit/setSelectionRangeIfNecessary.ts create mode 100644 src/utils/focus/getActiveElement.ts create mode 100644 src/utils/focus/isFocusable.ts create mode 100644 src/utils/focus/selector.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/misc/eventWrapper.ts create mode 100644 src/utils/misc/isDisabled.ts create mode 100644 src/utils/misc/isInstanceOfElement.ts create mode 100644 src/utils/misc/isLabelWithInternallyDisabledControl.ts create mode 100644 src/utils/misc/isVisible.ts create mode 100644 src/utils/misc/wait.ts create mode 100644 tsconfig.json create mode 100644 typings/dom-helpers.d.ts diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..b9e85808 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + extends: './node_modules/kcd-scripts/eslint.js', + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.ts'], + }, + }, + }, + rules: { + 'testing-library/no-dom-import': 0, + '@typescript-eslint/non-nullable-type-assertion-style': 0, + }, +} diff --git a/README.md b/README.md index 3abeee53..751c6418 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ change the state of the checkbox. - [`click(element, eventInit, options)`](#clickelement-eventinit-options) - [`dblClick(element, eventInit, options)`](#dblclickelement-eventinit-options) - [`type(element, text, [options])`](#typeelement-text-options) - - [`upload(element, file, [{ clickInit, changeInit }])`](#uploadelement-file--clickinit-changeinit-) + - [`keyboard(text, options)`](#keyboardtext-options) + - [`upload(element, file, [{ clickInit, changeInit }], [options])`](#uploadelement-file--clickinit-changeinit--options) - [`clear(element)`](#clearelement) - [`selectOptions(element, values)`](#selectoptionselement-values) - [`deselectOptions(element, values)`](#deselectoptionselement-values) @@ -178,10 +179,6 @@ are typed. By default it's 0. You can use this option if your component has a different behavior for fast or slow users. If you do this, you need to make sure to `await`! -> To be clear, `userEvent.type` _always_ returns a promise, but you _only_ need -> to `await` the promise it returns if you're using the `delay` option. -> Otherwise everything runs synchronously and you can ignore the promise. - `type` will click the element before typing. To disable this, set the `skipClick` option to `true`. @@ -271,6 +268,76 @@ test('types into the input', () => { }) ``` +### `keyboard(text, options)` + +Simulates the keyboard events described by `text`. This is similar to +`userEvent.type()` but without any clicking or changing the selection range. + +> You should use `userEvent.keyboard` if you want to just simulate pressing +> buttons on the keyboard. You should use `userEvent.type` if you just want to +> conveniently insert some text into an input field or textarea. + +Keystrokes can be described: + +- Per printable character + ```js + userEvent.keyboard('foo') // translates to: f, o, o + ``` + The brackets `{` and `[` are used as special character and can be referenced + by doubling them. + ```js + userEvent.keyboard('{{a[[') // translates to: {, a, [ + ``` +- Per + [KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) + (only supports alphanumeric values of `key`) + ```js + userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o + ``` + This does not keep any key pressed. So `Shift` will be lifted before pressing + `f`. +- Per + [KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) + ```js + userEvent.keyboard('[ShiftLeft][KeyF][KeyO][KeyO]') // translates to: Shift, f, o, o + ``` +- Per legacy `userEvent.type` modifier/specialChar The modifiers like `{shift}` + (note the lowercase) will automatically be kept pressed, just like before. You + can cancel this behavior by adding a `/` to the end of the descriptor. + ```js + userEvent.keyboard('{shift}{ctrl/}a{/shift}') // translates to: Shift(down), Control(down+up), a, Shift(up) + ``` + +Keys can be kept pressed by adding a `>` to the end of the descriptor - and +lifted by adding a `/` to the beginning of the descriptor: + +```js +userEvent.keyboard('{Shift>}A{/Shift}') // translates to: Shift(down), A, Shift(up) +``` + +`userEvent.keyboard` returns a keyboard state that can be used to continue +keyboard operations. + +```js +const keyboardState = userEvent.keyboard('[ControlLeft>]') // keydown [ControlLeft] +// ... inspect some changes ... +userEvent.keyboard('a', {keyboardState}) // press [KeyA] with active ctrlKey modifier +``` + +The mapping of `key` to `code` is performed by a +[default key map](https://github.com/testing-library/user-event/blob/master/src/keyboard/keyMap.ts) +portraying a "default" US-keyboard. You can provide your own local keyboard +mapping per option. + +```js +userEvent.keyboard('?', {keyboardMap: myOwnLocaleKeyboardMap}) +``` + +> Future versions might try to interpolate the modifiers needed to reach a +> printable key on the keyboard. E.g. Automatically pressing `{Shift}` when +> CapsLock is not active and `A` is referenced. If you don't wish this behavior, +> you can pass `autoModify: false` when using `userEvent.keyboard` in your code. + ### `upload(element, file, [{ clickInit, changeInit }], [options])` Uploads file to an ``. For uploading multiple files use `` with diff --git a/package.json b/package.json index 766de19f..9e4ee802 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "@testing-library/dom": "^7.28.1", "@testing-library/jest-dom": "^5.11.6", "@types/estree": "0.0.45", + "@types/jest-in-case": "^1.0.3", "is-ci": "^2.0.0", + "jest-in-case": "^1.0.2", "jest-serializer-ansi": "^1.0.3", "kcd-scripts": "^7.5.1", "typescript": "^4.1.2" diff --git a/src/__tests__/keyboard/getNextKeyDef.ts b/src/__tests__/keyboard/getNextKeyDef.ts new file mode 100644 index 00000000..fc6344f7 --- /dev/null +++ b/src/__tests__/keyboard/getNextKeyDef.ts @@ -0,0 +1,78 @@ +import cases from 'jest-in-case' +import {getNextKeyDef} from 'keyboard/getNextKeyDef' +import {defaultKeyMap} from 'keyboard/keyMap' +import {keyboardKey, keyboardOptions} from 'keyboard/types' + +const options: keyboardOptions = { + document, + keyboardMap: defaultKeyMap, + autoModify: false, + delay: 123, +} + +cases( + 'reference key per', + ({text, key, code}) => { + expect(getNextKeyDef(`${text}foo`, options)).toEqual( + expect.objectContaining({ + keyDef: expect.objectContaining({ + key, + code, + }) as keyboardKey, + }), + ) + }, + { + code: {text: '[ControlLeft]', key: 'Control', code: 'ControlLeft'}, + 'unimplemented code': {text: '[Foo]', key: 'Unknown', code: 'Foo'}, + key: {text: '{Control}', key: 'Control', code: 'ControlLeft'}, + 'unimplemented key': {text: '{Foo}', key: 'Foo', code: 'Unknown'}, + 'legacy modifier': {text: '{ctrl}', key: 'Control', code: 'ControlLeft'}, + 'printable character': {text: 'a', key: 'a', code: 'KeyA'}, + '{ as printable': {text: '{{', key: '{', code: 'Unknown'}, + '[ as printable': {text: '[[', key: '[', code: 'Unknown'}, + }, +) + +cases( + 'modifiers', + ({text, modifiers}) => { + expect(getNextKeyDef(`${text}foo`, options)).toEqual( + expect.objectContaining(modifiers), + ) + }, + { + 'no releasePrevious': { + text: '{Control}', + modifiers: {releasePrevious: false}, + }, + 'releasePrevious per key': { + text: '{/Control}', + modifiers: {releasePrevious: true}, + }, + 'releasePrevious per code': { + text: '[/ControlLeft]', + modifiers: {releasePrevious: true}, + }, + 'default releaseSelf': { + text: '{Control}', + modifiers: {releaseSelf: true}, + }, + 'keep key pressed per key': { + text: '{Control>}', + modifiers: {releaseSelf: false}, + }, + 'keep key pressed per code': { + text: '[Control>]', + modifiers: {releaseSelf: false}, + }, + 'no releaseSelf on legacy modifier': { + text: '{ctrl}', + modifiers: {releaseSelf: false}, + }, + 'release legacy modifier': { + text: '{ctrl/}', + modifiers: {releaseSelf: true}, + }, + }, +) diff --git a/src/__tests__/keyboard/index.ts b/src/__tests__/keyboard/index.ts new file mode 100644 index 00000000..a8e4770b --- /dev/null +++ b/src/__tests__/keyboard/index.ts @@ -0,0 +1,137 @@ +import userEvent from '../../index' +import {addListeners, setup} from '../helpers/utils' + +it('type without focus', () => { + const {element} = setup('') + const {getEventSnapshot} = addListeners(document.body) + + userEvent.keyboard('foo') + + expect(element).toHaveValue('') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: body + + body - keydown: f (102) + body - keypress: f (102) + body - keyup: f (102) + body - keydown: o (111) + body - keypress: o (111) + body - keyup: o (111) + body - keydown: o (111) + body - keypress: o (111) + body - keyup: o (111) + `) +}) + +it('type with focus', () => { + const {element} = setup('') + const {getEventSnapshot} = addListeners(document.body) + ;(element as HTMLInputElement).focus() + + userEvent.keyboard('foo') + + expect(element).toHaveValue('foo') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: body + + input[value=""] - focusin + input[value=""] - keydown: f (102) + input[value=""] - keypress: f (102) + input[value="f"] - input + input[value="f"] - keyup: f (102) + input[value="f"] - keydown: o (111) + input[value="f"] - keypress: o (111) + input[value="fo"] - input + input[value="fo"] - keyup: o (111) + input[value="fo"] - keydown: o (111) + input[value="fo"] - keypress: o (111) + input[value="foo"] - input + input[value="foo"] - keyup: o (111) + `) +}) + +it('type asynchronous', async () => { + const {element} = setup('') + const {getEventSnapshot} = addListeners(document.body) + ;(element as HTMLInputElement).focus() + + // eslint-disable-next-line testing-library/no-await-sync-events + await userEvent.keyboard('foo', {delay: 1}) + + expect(element).toHaveValue('foo') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: body + + input[value=""] - focusin + input[value=""] - keydown: f (102) + input[value=""] - keypress: f (102) + input[value="f"] - input + input[value="f"] - keyup: f (102) + input[value="f"] - keydown: o (111) + input[value="f"] - keypress: o (111) + input[value="fo"] - input + input[value="fo"] - keyup: o (111) + input[value="fo"] - keydown: o (111) + input[value="fo"] - keypress: o (111) + input[value="foo"] - input + input[value="foo"] - keyup: o (111) + `) +}) + +describe('error', () => { + afterEach(() => { + ;(console.error as jest.MockedFunction).mockClear() + }) + + it('error in sync', async () => { + const err = jest.spyOn(console, 'error') + err.mockImplementation(() => {}) + + userEvent.keyboard('{!') + + // the catch will be asynchronous + await Promise.resolve() + + expect(err).toHaveBeenCalledWith(expect.any(Error)) + expect(err.mock.calls[0][0]).toHaveProperty( + 'message', + 'Expected key descriptor but found "!" in "{!"', + ) + }) + + it('error in async', async () => { + const promise = userEvent.keyboard('{!', {delay: 1}) + + return expect(promise).rejects.toThrowError( + 'Expected key descriptor but found "!" in "{!"', + ) + }) +}) + +it('continue typing with state', () => { + const {element, getEventSnapshot, clearEventCalls} = setup('') + ;(element as HTMLInputElement).focus() + clearEventCalls() + + const state = userEvent.keyboard('[ShiftRight>]') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + input[value=""] - keydown: Shift (16) {shift} + `) + clearEventCalls() + + userEvent.keyboard('F[/ShiftRight]', {keyboardState: state}) + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="F"] + + input[value=""] - keydown: F (70) {shift} + input[value=""] - keypress: F (70) {shift} + input[value="F"] - input + "{CURSOR}" -> "F{CURSOR}" + input[value="F"] - keyup: F (70) {shift} + input[value="F"] - keyup: Shift (16) + `) +}) diff --git a/src/__tests__/type-modifiers.js b/src/__tests__/type-modifiers.js index 4bfe28b0..c0a447b2 100644 --- a/src/__tests__/type-modifiers.js +++ b/src/__tests__/type-modifiers.js @@ -353,10 +353,11 @@ test('{shift}a{/shift}', () => { `) }) -test('{capslock}a{/capslock}', () => { +test('{capslock}a{capslock}', () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '{capslock}a{/capslock}') + // The old behavior to treat {/capslock} like {capslock} makes no sense + userEvent.type(element, '{capslock}a{capslock}') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="a"] diff --git a/src/__tests__/type.js b/src/__tests__/type.js index 9940e78a..7cc35808 100644 --- a/src/__tests__/type.js +++ b/src/__tests__/type.js @@ -246,7 +246,7 @@ test('should delay the typing when opts.delay is not 0', async () => { await userEvent.type(element, text, {delay}) expect(onInput).toHaveBeenCalledTimes(text.length) - for (let index = 1; index < inputValues.length; index++) { + for (let index = 2; index < inputValues.length; index++) { const {timestamp, value} = inputValues[index] expect(timestamp - inputValues[index - 1].timestamp).toBeGreaterThanOrEqual( delay, @@ -792,9 +792,7 @@ test('typing an invalid input value', () => { const {element} = setup('') userEvent.type(element, '3-3') - // TODO: fix this bug - // THIS IS A BUG! It should be expect(element.value).toBe('') - expect(element).toHaveValue(-3) + expect(element).toHaveValue(null) // THIS IS A LIMITATION OF THE BROWSER // It is impossible to programmatically set an input @@ -805,10 +803,10 @@ test('typing an invalid input value', () => { test('should not throw error if we are trying to call type on an element without a value', () => { const {element} = setup('
') - expect.assertions(0) - return userEvent - .type(element, "I'm only a div :(") - .catch(e => expect(e).toBeUndefined()) + + return expect(userEvent.type(element, ':(', {delay: 1})).resolves.toBe( + undefined, + ) }) test('typing on button should not alter its value', () => { @@ -849,8 +847,10 @@ test('typing on input type submit should not alter its value', () => { test('typing on input type file should not result in an error', () => { const {element} = setup('') - expect.assertions(0) - return userEvent.type(element, 'bar').catch(e => expect(e).toBeUndefined()) + + return expect(userEvent.type(element, 'bar', {delay: 1})).resolves.toBe( + undefined, + ) }) test('should submit a form containing multiple text inputs and an input of type submit', () => { diff --git a/src/__tests__/type/plugin/arrow.ts b/src/__tests__/type/plugin/arrow.ts new file mode 100644 index 00000000..3dea28c1 --- /dev/null +++ b/src/__tests__/type/plugin/arrow.ts @@ -0,0 +1,45 @@ +import userEvent from 'index' +import {setup} from '__tests__/helpers/utils' + +const setupInput = () => + setup(``).element as HTMLInputElement + +test('collapse selection to the left', () => { + const el = setupInput() + el.setSelectionRange(2, 4) + + userEvent.type(el, '[ArrowLeft]') + + expect(el.selectionStart).toBe(2) + expect(el.selectionEnd).toBe(2) +}) + +test('collapse selection to the right', () => { + const el = setupInput() + el.setSelectionRange(2, 4) + + userEvent.type(el, '[ArrowRight]') + + expect(el.selectionStart).toBe(4) + expect(el.selectionEnd).toBe(4) +}) + +test('move cursor left', () => { + const el = setupInput() + el.setSelectionRange(2, 2) + + userEvent.type(el, '[ArrowLeft]') + + expect(el.selectionStart).toBe(1) + expect(el.selectionEnd).toBe(1) +}) + +test('move cursor right', () => { + const el = setupInput() + el.setSelectionRange(2, 2) + + userEvent.type(el, '[ArrowRight]') + + expect(el.selectionStart).toBe(3) + expect(el.selectionEnd).toBe(3) +}) diff --git a/src/__tests__/type/plugin/character.ts b/src/__tests__/type/plugin/character.ts new file mode 100644 index 00000000..095acea1 --- /dev/null +++ b/src/__tests__/type/plugin/character.ts @@ -0,0 +1,19 @@ +import userEvent from 'index' +import {setup} from '__tests__/helpers/utils' + +test('type [Enter] in textarea', () => { + const {element} = setup(``) + + userEvent.type(element as HTMLTextAreaElement, 'oo[Enter]bar') + + expect(element).toHaveValue('foo\nbar') +}) + +test('type [Enter] in contenteditable', () => { + const {element} = setup(`
f
`) + + userEvent.type(element as HTMLTextAreaElement, 'oo[Enter]bar') + + expect(element).toHaveTextContent('foo bar') + expect(element?.firstChild).toHaveProperty('nodeValue', 'foo\nbar') +}) diff --git a/src/__tests__/type/plugin/control.ts b/src/__tests__/type/plugin/control.ts new file mode 100644 index 00000000..9f0e476a --- /dev/null +++ b/src/__tests__/type/plugin/control.ts @@ -0,0 +1,48 @@ +import userEvent from 'index' +import {setup} from '__tests__/helpers/utils' + +test('press [Home] in textarea', () => { + const {element} = setup(``) + ;(element as HTMLTextAreaElement).setSelectionRange(2, 4) + + userEvent.type(element as HTMLTextAreaElement, '[Home]') + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 0) +}) + +test('press [Home] in contenteditable', () => { + const {element} = setup(`
foo\nbar\baz
`) + document + .getSelection() + ?.setPosition((element as HTMLDivElement).firstChild, 2) + + userEvent.type(element as HTMLTextAreaElement, '[Home]') + + const selection = document.getSelection() + expect(selection).toHaveProperty('focusNode', element?.firstChild) + expect(selection).toHaveProperty('focusOffset', 0) +}) + +test('press [End] in textarea', () => { + const {element} = setup(``) + ;(element as HTMLTextAreaElement).setSelectionRange(2, 4) + + userEvent.type(element as HTMLTextAreaElement, '[End]') + + expect(element).toHaveProperty('selectionStart', 10) + expect(element).toHaveProperty('selectionEnd', 10) +}) + +test('press [End] in contenteditable', () => { + const {element} = setup(`
foo\nbar\baz
`) + document + .getSelection() + ?.setPosition((element as HTMLDivElement).firstChild, 2) + + userEvent.type(element as HTMLTextAreaElement, '[End]') + + const selection = document.getSelection() + expect(selection).toHaveProperty('focusNode', element?.firstChild) + expect(selection).toHaveProperty('focusOffset', 10) +}) diff --git a/src/__tests__/type/plugin/functional.ts b/src/__tests__/type/plugin/functional.ts new file mode 100644 index 00000000..dbc34938 --- /dev/null +++ b/src/__tests__/type/plugin/functional.ts @@ -0,0 +1,76 @@ +import userEvent from 'index' +import {setup} from '__tests__/helpers/utils' + +test('produce extra events for the Control key when AltGraph is pressed', () => { + const {element, getEventSnapshot} = setup(``) + + userEvent.type(element as Element, '{AltGraph}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + input[value=""] - pointerover + input[value=""] - pointerenter + input[value=""] - mouseover: Left (0) + input[value=""] - mouseenter: Left (0) + input[value=""] - pointermove + input[value=""] - mousemove: Left (0) + input[value=""] - pointerdown + input[value=""] - mousedown: Left (0) + input[value=""] - focus + input[value=""] - focusin + input[value=""] - pointerup + input[value=""] - mouseup: Left (0) + input[value=""] - click: Left (0) + input[value=""] - keydown: Control (17) + input[value=""] - keydown: AltGraph (0) + input[value=""] - keyup: AltGraph (0) + input[value=""] - keyup: Control (17) + `) +}) + +test('backspace to valid value', () => { + const {element, getEventSnapshot} = setup(``) + + userEvent.type(element as Element, '5e-[Backspace][Backspace]') + + expect(element).toHaveValue(5) + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="5"] + + input[value=""] - pointerover + input[value=""] - pointerenter + input[value=""] - mouseover: Left (0) + input[value=""] - mouseenter: Left (0) + input[value=""] - pointermove + input[value=""] - mousemove: Left (0) + input[value=""] - pointerdown + input[value=""] - mousedown: Left (0) + input[value=""] - focus + input[value=""] - focusin + input[value=""] - pointerup + input[value=""] - mouseup: Left (0) + input[value=""] - click: Left (0) + input[value=""] - keydown: 5 (53) + input[value=""] - keypress: 5 (53) + input[value="5"] - input + "{CURSOR}" -> "{CURSOR}5" + input[value="5"] - keyup: 5 (53) + input[value="5"] - keydown: e (101) + input[value="5"] - keypress: e (101) + input[value=""] - input + "{CURSOR}5" -> "{CURSOR}" + input[value=""] - keyup: e (101) + input[value=""] - keydown: - (45) + input[value=""] - keypress: - (45) + input[value=""] - input + input[value=""] - keyup: - (45) + input[value=""] - keydown: Backspace (8) + input[value=""] - input + input[value=""] - keyup: Backspace (8) + input[value=""] - keydown: Backspace (8) + input[value="5"] - input + "{CURSOR}" -> "{CURSOR}5" + input[value="5"] - keyup: Backspace (8) + `) +}) diff --git a/src/click.d.ts b/src/click.d.ts new file mode 100644 index 00000000..57ac45fc --- /dev/null +++ b/src/click.d.ts @@ -0,0 +1,10 @@ +export declare interface clickOptions { + skipHover?: boolean + clickCount?: number +} + +export declare function click( + element: TargetElement, + init?: MouseEventInit, + options?: clickOptions, +): void diff --git a/src/index.js b/src/index.js index 2b610d97..28e4750a 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import {hover, unhover} from './hover' import {upload} from './upload' import {selectOptions, deselectOptions} from './select-options' import {paste} from './paste' +import {keyboard} from './keyboard' const userEvent = { click, @@ -19,6 +20,7 @@ const userEvent = { selectOptions, deselectOptions, paste, + keyboard, } export default userEvent diff --git a/src/keyboard/getEventProps.ts b/src/keyboard/getEventProps.ts new file mode 100644 index 00000000..a530c8dd --- /dev/null +++ b/src/keyboard/getEventProps.ts @@ -0,0 +1,27 @@ +import {keyboardKey, keyboardState} from './types' + +export function getKeyEventProps(keyDef: keyboardKey, state: keyboardState) { + return { + key: keyDef.key, + code: keyDef.code, + altKey: state.modifiers.alt, + ctrlKey: state.modifiers.ctrl, + metaKey: state.modifiers.meta, + shiftKey: state.modifiers.shift, + + /** @deprecated use code instead */ + keyCode: + keyDef.keyCode ?? + // istanbul ignore next + (keyDef.key?.length === 1 ? keyDef.key.charCodeAt(0) : undefined), + } +} + +export function getMouseEventProps(state: keyboardState) { + return { + altKey: state.modifiers.alt, + ctrlKey: state.modifiers.ctrl, + metaKey: state.modifiers.meta, + shiftKey: state.modifiers.shift, + } +} diff --git a/src/keyboard/getNextKeyDef.ts b/src/keyboard/getNextKeyDef.ts new file mode 100644 index 00000000..bea29afb --- /dev/null +++ b/src/keyboard/getNextKeyDef.ts @@ -0,0 +1,135 @@ +import {keyboardKey, keyboardOptions} from './types' + +/** + * Get the next key from keyMap + * + * Keys can be referenced by `{key}` or `{special}` as well as physical locations per `[code]`. + * Everything else will be interpreted as a typed character - e.g. `a`. + * Brackets `{` and `[` can be escaped by doubling - e.g. `foo[[bar` translates to `foo[bar`. + * Keeping the key pressed can be written as `{key>}`. + * Modifiers like `{shift}` imply being kept pressed. This can be turned of per `{shift/}`. + */ +export function getNextKeyDef( + text: string, + options: keyboardOptions, +): { + keyDef: keyboardKey + consumedLength: number + releasePrevious: boolean + releaseSelf: boolean +} { + const startBracket = ['{', '['].includes(text[0]) ? text[0] : '' + const startModifier = text[1] === '/' ? '/' : '' + + const descriptorStart = startBracket.length + startModifier.length + const descriptor = startBracket + ? text[descriptorStart] === startBracket + ? startBracket + : text.slice(descriptorStart).match(/^\w+/)?.[0] + : text[descriptorStart] + + // istanbul ignore if + if (!descriptor) { + throw new Error( + `Expected key descriptor but found "${text[descriptorStart]}" in "${text}"`, + ) + } + + const descriptorEnd = descriptorStart + descriptor.length + const endModifier = + descriptor !== startBracket && ['/', '>'].includes(text[descriptorEnd]) + ? text[descriptorEnd] + : '' + + const endBracket = + !startBracket || descriptor === startBracket + ? '' + : startBracket === '{' + ? '}' + : ']' + + // istanbul ignore if + if (endBracket && text[descriptorEnd + endModifier.length] !== endBracket) { + throw new Error( + `Expected closing bracket but found "${ + text[descriptorEnd + endModifier.length] + }" in "${text}"`, + ) + } + + const modifiers = { + consumedLength: [ + startBracket, + startModifier, + descriptor, + endModifier, + endBracket, + ] + .map(c => c.length) + .reduce((a, b) => a + b), + + releasePrevious: startModifier === '/', + releaseSelf: hasReleaseSelf(startBracket, descriptor, endModifier), + } + + if (isPrintableCharacter(startBracket, descriptor)) { + return { + ...modifiers, + keyDef: options.keyboardMap.find(k => k.key === descriptor) ?? { + key: descriptor, + code: 'Unknown', + }, + } + } else if (startBracket === '{') { + const key = mapLegacyKey(descriptor) + return { + ...modifiers, + keyDef: options.keyboardMap.find( + k => k.key?.toLowerCase() === key.toLowerCase(), + ) ?? {key: descriptor, code: 'Unknown'}, + } + } else { + return { + ...modifiers, + keyDef: options.keyboardMap.find( + k => k.code?.toLowerCase() === descriptor.toLowerCase(), + ) ?? {key: 'Unknown', code: descriptor}, + } + } +} + +function hasReleaseSelf( + startBracket: string, + descriptor: string, + endModifier: string, +) { + if (endModifier === '/' || !startBracket) { + return true + } + if ( + startBracket === '{' && + ['alt', 'ctrl', 'meta', 'shift'].includes(descriptor.toLowerCase()) + ) { + return false + } + return endModifier !== '>' +} + +function mapLegacyKey(descriptor: string) { + return ( + { + ctrl: 'Control', + del: 'Delete', + esc: 'Escape', + space: ' ', + }[descriptor] ?? descriptor + ) +} + +function isPrintableCharacter(startBracket: string, descriptor: string) { + return ( + !startBracket || + startBracket === descriptor || + (startBracket === '{' && descriptor.length === 1) + ) +} diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts new file mode 100644 index 00000000..36827e3d --- /dev/null +++ b/src/keyboard/index.ts @@ -0,0 +1,75 @@ +import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {keyboardImplementation, releaseAllKeys} from './keyboardImplementation' +import {defaultKeyMap} from './keyMap' +import {keyboardState, keyboardOptions, keyboardKey} from './types' + +export {specialCharMap} from './specialCharMap' +export type {keyboardOptions, keyboardKey} + +export function keyboard( + text: string, + options?: Partial, +): keyboardState +export function keyboard( + text: string, + options: Partial< + keyboardOptions & {keyboardState: keyboardState; delay: number} + >, +): Promise +export function keyboard( + text: string, + options?: Partial, +): keyboardState | Promise { + const {promise, state} = keyboardImplementationWrapper(text, options) + + if ((options?.delay ?? 0) > 0) { + return getDOMTestingLibraryConfig().asyncWrapper(() => + promise.then(() => state), + ) + } else { + // prevent users from dealing with UnhandledPromiseRejectionWarning in sync call + promise.catch(console.error) + + return state + } +} + +export function keyboardImplementationWrapper( + text: string, + config: Partial = {}, +) { + const { + keyboardState: state = createKeyboardState(), + delay = 0, + document: doc = document, + autoModify = false, + keyboardMap = defaultKeyMap, + } = config + const options = { + delay, + document: doc, + autoModify, + keyboardMap, + } + + return { + promise: keyboardImplementation(text, options, state), + state, + releaseAllKeys: () => releaseAllKeys(options, state), + } +} + +function createKeyboardState(): keyboardState { + return { + activeElement: null, + pressed: [], + carryChar: '', + modifiers: { + alt: false, + caps: false, + ctrl: false, + meta: false, + shift: false, + }, + } +} diff --git a/src/keyboard/keyMap.ts b/src/keyboard/keyMap.ts new file mode 100644 index 00000000..0e0a5520 --- /dev/null +++ b/src/keyboard/keyMap.ts @@ -0,0 +1,79 @@ +import {DOM_KEY_LOCATION, keyboardKey} from './types' + +/** + * Mapping for a default US-104-QWERTY keyboard + */ +export const defaultKeyMap: keyboardKey[] = [ + // alphanumeric keys + ...'0123456789'.split('').map(c => ({code: `Digit${c}`, key: c})), + ...')!@#$%^&*(' + .split('') + .map((c, i) => ({code: `Digit${i}`, key: c, shiftKey: true})), + ...'abcdefghijklmnopqrstuvwxyz' + .split('') + .map(c => ({code: `Key${c.toUpperCase()}`, key: c})), + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + .split('') + .map(c => ({code: `Key${c}`, key: c, shiftKey: true})), + + // alphanumeric block - functional + {code: 'Space', key: ' '}, + + {code: 'AltLeft', key: 'Alt', location: DOM_KEY_LOCATION.LEFT, keyCode: 18}, + {code: 'AltRight', key: 'Alt', location: DOM_KEY_LOCATION.RIGHT, keyCode: 18}, + { + code: 'ShiftLeft', + key: 'Shift', + location: DOM_KEY_LOCATION.LEFT, + keyCode: 16, + }, + { + code: 'ShiftRight', + key: 'Shift', + location: DOM_KEY_LOCATION.RIGHT, + keyCode: 16, + }, + { + code: 'ControlLeft', + key: 'Control', + location: DOM_KEY_LOCATION.LEFT, + keyCode: 17, + }, + { + code: 'ControlRight', + key: 'Control', + location: DOM_KEY_LOCATION.RIGHT, + keyCode: 17, + }, + {code: 'MetaLeft', key: 'Meta', location: DOM_KEY_LOCATION.LEFT, keyCode: 93}, + { + code: 'MetaRight', + key: 'Meta', + location: DOM_KEY_LOCATION.RIGHT, + keyCode: 93, + }, + + {code: 'OSLeft', key: 'OS', location: DOM_KEY_LOCATION.LEFT, keyCode: 91}, + {code: 'OSRight', key: 'OS', location: DOM_KEY_LOCATION.RIGHT, keyCode: 91}, + + {code: 'Escape', key: 'CapsLock', keyCode: 20}, + {code: 'CapsLock', key: 'CapsLock', keyCode: 20}, + {code: 'Backspace', key: 'Backspace', keyCode: 8}, + {code: 'Enter', key: 'Enter', keyCode: 13}, + + // function + {code: 'Escape', key: 'Escape', keyCode: 27}, + + // arrows + {code: 'ArrowUp', key: 'ArrowUp', keyCode: 38}, + {code: 'ArrowDown', key: 'ArrowDown', keyCode: 40}, + {code: 'ArrowLeft', key: 'ArrowLeft', keyCode: 37}, + {code: 'ArrowRight', key: 'ArrowRight', keyCode: 39}, + + // control pad + {code: 'Home', key: 'Home', keyCode: 36}, + {code: 'End', key: 'End', keyCode: 35}, + {code: 'Delete', key: 'Delete', keyCode: 46}, + + // TODO: add mappings +] diff --git a/src/keyboard/keyboardImplementation.ts b/src/keyboard/keyboardImplementation.ts new file mode 100644 index 00000000..c6626cc6 --- /dev/null +++ b/src/keyboard/keyboardImplementation.ts @@ -0,0 +1,196 @@ +import {fireEvent} from '@testing-library/dom' +import {getActiveElement, wait} from '../utils' +import {getNextKeyDef} from './getNextKeyDef' +import { + behaviorPlugin, + keyboardKey, + keyboardState, + keyboardOptions, +} from './types' +import * as plugins from './plugins' +import {getKeyEventProps} from './getEventProps' + +export async function keyboardImplementation( + text: string, + options: keyboardOptions, + state: keyboardState, +): Promise { + const {document} = options + const getCurrentElement = () => getActive(document) + + const {keyDef, consumedLength, releasePrevious, releaseSelf} = getNextKeyDef( + text, + options, + ) + + const replace = applyPlugins( + plugins.replaceBehavior, + keyDef, + getCurrentElement(), + options, + state, + ) + if (!replace) { + const pressed = state.pressed.find(p => p.keyDef === keyDef) + + if (pressed) { + keyup( + keyDef, + getCurrentElement, + options, + state, + pressed.unpreventedDefault, + ) + } + + if (!releasePrevious) { + const unpreventedDefault = keydown( + keyDef, + getCurrentElement, + options, + state, + ) + + if ( + unpreventedDefault && + (keyDef.key?.length === 1 || keyDef.key === 'Enter') + ) { + keypress(keyDef, getCurrentElement, options, state) + } + + if (releaseSelf) { + keyup(keyDef, getCurrentElement, options, state, unpreventedDefault) + } + } + } + + if (text.length > consumedLength) { + if (options.delay > 0) { + await wait(options.delay) + } + return keyboardImplementation(text.slice(consumedLength), options, state) + } + return void undefined +} + +function getActive(document: Document): Element { + return getActiveElement(document) ?? /* istanbul ignore next */ document.body +} + +export function releaseAllKeys(options: keyboardOptions, state: keyboardState) { + const getCurrentElement = () => getActive(options.document) + for (const k of state.pressed) { + keyup(k.keyDef, getCurrentElement, options, state, k.unpreventedDefault) + } +} + +function keydown( + keyDef: keyboardKey, + getCurrentElement: () => Element, + options: keyboardOptions, + state: keyboardState, +) { + const element = getCurrentElement() + + // clear carried characters when focus is moved + if (element !== state.activeElement) { + state.carryValue = undefined + state.carryChar = '' + } + state.activeElement = element + + applyPlugins(plugins.preKeydownBehavior, keyDef, element, options, state) + + const unpreventedDefault = fireEvent.keyDown( + element, + getKeyEventProps(keyDef, state), + ) + + state.pressed.push({keyDef, unpreventedDefault}) + + if (unpreventedDefault) { + // all default behavior like keypress/submit etc is applied to the currentElement + applyPlugins( + plugins.keydownBehavior, + keyDef, + getCurrentElement(), + options, + state, + ) + } + + return unpreventedDefault +} + +function keypress( + keyDef: keyboardKey, + getCurrentElement: () => Element, + options: keyboardOptions, + state: keyboardState, +) { + const element = getCurrentElement() + + const unpreventedDefault = fireEvent.keyPress( + element, + getKeyEventProps(keyDef, state), + ) + + if (unpreventedDefault) { + applyPlugins( + plugins.keypressBehavior, + keyDef, + getCurrentElement(), + options, + state, + ) + } +} + +function keyup( + keyDef: keyboardKey, + getCurrentElement: () => Element, + options: keyboardOptions, + state: keyboardState, + unprevented: boolean, +) { + const element = getCurrentElement() + + applyPlugins(plugins.preKeyupBehavior, keyDef, element, options, state) + + const unpreventedDefault = fireEvent.keyUp( + element, + getKeyEventProps(keyDef, state), + ) + + if (unprevented && unpreventedDefault) { + applyPlugins( + plugins.keyupBehavior, + keyDef, + getCurrentElement(), + options, + state, + ) + } + + state.pressed = state.pressed.filter(k => k.keyDef !== keyDef) + + applyPlugins(plugins.postKeyupBehavior, keyDef, element, options, state) +} + +function applyPlugins( + pluginCollection: behaviorPlugin[], + keyDef: keyboardKey, + element: Element, + options: keyboardOptions, + state: keyboardState, +): boolean { + const plugin = pluginCollection.find(p => + p.matches(keyDef, element, options, state), + ) + + if (plugin) { + plugin.handle(keyDef, element, options, state) + } + + return !!plugin +} diff --git a/src/keyboard/plugins/arrow.ts b/src/keyboard/plugins/arrow.ts new file mode 100644 index 00000000..4455d7f1 --- /dev/null +++ b/src/keyboard/plugins/arrow.ts @@ -0,0 +1,30 @@ +/** + * This file should contain behavior for arrow keys as described here: + * https://w3c.github.io/uievents-code/#key-arrowpad-section + */ + +import {behaviorPlugin} from '../types' +import {isInstanceOfElement, setSelectionRangeIfNecessary} from '../../utils' + +export const keydownBehavior: behaviorPlugin[] = [ + { + // TODO: implement for textarea and contentEditable + matches: (keyDef, element) => + (keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight') && + isInstanceOfElement(element, 'HTMLInputElement'), + handle: (keyDef, element) => { + const {selectionStart, selectionEnd} = element as HTMLInputElement + + const direction = keyDef.key === 'ArrowLeft' ? -1 : 1 + + const newPos = + (selectionStart === selectionEnd + ? (selectionStart ?? /* istanbul ignore next */ 0) + direction + : direction < 0 + ? selectionStart + : selectionEnd) ?? /* istanbul ignore next */ 0 + + setSelectionRangeIfNecessary(element, newPos, newPos) + }, + }, +] diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts new file mode 100644 index 00000000..31d523d6 --- /dev/null +++ b/src/keyboard/plugins/character.ts @@ -0,0 +1,184 @@ +/** + * This file should cover the behavior for keys that produce character input + */ + +import {fireEvent} from '@testing-library/dom' +import {fireChangeForInputTimeIfValid, fireInputEventIfNeeded} from '../shared' +import {behaviorPlugin} from '../types' +import { + buildTimeValue, + calculateNewValue, + getValue, + isContentEditable, + isInstanceOfElement, + isValidDateValue, + isValidInputTimeValue, +} from '../../utils' + +export const keypressBehavior: behaviorPlugin[] = [ + { + matches: (keyDef, element) => + keyDef.key?.length === 1 && + isInstanceOfElement(element, 'HTMLInputElement') && + (element as HTMLInputElement).type === 'time', + handle: (keyDef, element, options, state) => { + let newEntry = keyDef.key as string + + const textToBeTyped = (state.carryValue ?? '') + newEntry + const timeNewEntry = buildTimeValue(textToBeTyped) + if ( + isValidInputTimeValue( + element as HTMLInputElement & {type: 'time'}, + timeNewEntry, + ) + ) { + newEntry = timeNewEntry + } + + const {newValue, newSelectionStart} = calculateNewValue( + newEntry, + element as HTMLElement, + ) + + const {prevValue} = fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides: { + data: keyDef.key, + inputType: 'insertText', + }, + currentElement: () => element, + }) + + fireChangeForInputTimeIfValid( + element as HTMLInputElement & {type: 'time'}, + prevValue, + timeNewEntry, + ) + + state.carryValue = textToBeTyped + }, + }, + { + matches: (keyDef, element) => + keyDef.key?.length === 1 && + isInstanceOfElement(element, 'HTMLInputElement') && + (element as HTMLInputElement).type === 'date', + handle: (keyDef, element, options, state) => { + let newEntry = keyDef.key as string + + const textToBeTyped = (state.carryValue ?? '') + newEntry + const isValidToBeTyped = isValidDateValue( + element as HTMLInputElement & {type: 'date'}, + textToBeTyped, + ) + if (isValidToBeTyped) { + newEntry = textToBeTyped + } + + const {newValue, newSelectionStart} = calculateNewValue( + newEntry, + element as HTMLElement, + ) + + fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides: { + data: keyDef.key, + inputType: 'insertText', + }, + currentElement: () => element, + }) + + if (isValidToBeTyped) { + fireEvent.change(element, { + target: {value: textToBeTyped}, + }) + } + + state.carryValue = textToBeTyped + }, + }, + { + matches: (keyDef, element) => + keyDef.key?.length === 1 && + isInstanceOfElement(element, 'HTMLInputElement') && + (element as HTMLInputElement).type === 'number', + handle: (keyDef, element, options, state) => { + if (!/[\d.\-e]/.test(keyDef.key as string)) { + return + } + + const oldValue = + state.carryValue ?? getValue(element) ?? /* istanbul ignore next */ '' + + const {newValue, newSelectionStart} = calculateNewValue( + keyDef.key as string, + element as HTMLElement, + oldValue, + ) + + fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides: { + data: keyDef.key, + inputType: 'insertText', + }, + currentElement: () => element, + }) + + const appliedValue = getValue(element) + if (appliedValue === newValue) { + state.carryValue = undefined + } else { + state.carryValue = newValue + } + }, + }, + { + matches: (keyDef, element) => + keyDef.key?.length === 1 && + (isInstanceOfElement(element, 'HTMLInputElement') || + isInstanceOfElement(element, 'HTMLTextAreaElement') || + isContentEditable(element)), + handle: (keyDef, element) => { + const {newValue, newSelectionStart} = calculateNewValue( + keyDef.key as string, + element as HTMLElement, + ) + + fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides: { + data: keyDef.key, + inputType: 'insertText', + }, + currentElement: () => element, + }) + }, + }, + { + matches: (keyDef, element) => + keyDef.key === 'Enter' && + (isInstanceOfElement(element, 'HTMLTextAreaElement') || + isContentEditable(element)), + handle: (keyDef, element) => { + const {newValue, newSelectionStart} = calculateNewValue( + '\n', + element as HTMLElement, + ) + + fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides: { + inputType: 'insertLineBreak', + }, + currentElement: () => element, + }) + }, + }, +] diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts new file mode 100644 index 00000000..304129b7 --- /dev/null +++ b/src/keyboard/plugins/control.ts @@ -0,0 +1,45 @@ +/** + * This file should contain behavior for arrow keys as described here: + * https://w3c.github.io/uievents-code/#key-controlpad-section + */ + +import {behaviorPlugin} from '../types' +import { + getValue, + isContentEditable, + isInstanceOfElement, + setSelectionRangeIfNecessary, +} from '../../utils' +import {fireInputEventIfNeeded} from '../shared' +import {calculateNewDeleteValue} from './control/calculateNewDeleteValue' + +export const keydownBehavior: behaviorPlugin[] = [ + { + matches: (keyDef, element) => + (keyDef.key === 'Home' || keyDef.key === 'End') && + (isInstanceOfElement(element, 'HTMLInputElement') || + isInstanceOfElement(element, 'HTMLTextAreaElement') || + isContentEditable(element)), + handle: (keyDef, element) => { + // This could probably been improved by collapsing a selection range + if (keyDef.key === 'Home') { + setSelectionRangeIfNecessary(element, 0, 0) + } else { + const newPos = getValue(element)?.length ?? /* istanbul ignore next */ 0 + setSelectionRangeIfNecessary(element, newPos, newPos) + } + }, + }, + { + matches: keyDef => keyDef.key === 'Delete', + handle: (keDef, element) => { + fireInputEventIfNeeded({ + ...calculateNewDeleteValue(element), + eventOverrides: { + inputType: 'deleteContentForward', + }, + currentElement: () => element, + }) + }, + }, +] diff --git a/src/keyboard/plugins/control/calculateNewDeleteValue.ts b/src/keyboard/plugins/control/calculateNewDeleteValue.ts new file mode 100644 index 00000000..febdde86 --- /dev/null +++ b/src/keyboard/plugins/control/calculateNewDeleteValue.ts @@ -0,0 +1,33 @@ +import {getSelectionRange, getValue} from '../../../utils' + +export function calculateNewDeleteValue(element: Element) { + const {selectionStart, selectionEnd} = getSelectionRange(element) + + // istanbul ignore next + const value = getValue(element) ?? '' + + let newValue + + if (selectionStart === null) { + // at the end of an input type that does not support selection ranges + // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793 + newValue = value + } else if (selectionStart === selectionEnd) { + if (selectionStart === 0) { + // at the beginning of the input + newValue = value.slice(1) + } else if (selectionStart === value.length) { + // at the end of the input + newValue = value + } else { + // in the middle of the input + newValue = value.slice(0, selectionStart) + value.slice(selectionEnd + 1) + } + } else { + // we have something selected + const firstPart = value.slice(0, selectionStart) + newValue = firstPart + value.slice(selectionEnd as number) + } + + return {newValue, newSelectionStart: selectionStart ?? 0} +} diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts new file mode 100644 index 00000000..66c4c7ac --- /dev/null +++ b/src/keyboard/plugins/functional.ts @@ -0,0 +1,140 @@ +/** + * This file should contain behavior for functional keys as described here: + * https://w3c.github.io/uievents-code/#key-alphanumeric-functional + */ + +import {fireEvent} from '@testing-library/dom' +import {getValue, isClickableInput, isInstanceOfElement} from '../../utils' +import {getKeyEventProps, getMouseEventProps} from '../getEventProps' +import {fireInputEventIfNeeded} from '../shared' +import {behaviorPlugin} from '../types' +import {calculateNewBackspaceValue} from './functional/calculateBackspaceValue' + +const modifierKeys = { + Alt: 'alt', + Control: 'ctrl', + Shift: 'shift', + Meta: 'meta', +} as const + +export const preKeydownBehavior: behaviorPlugin[] = [ + // modifierKeys switch on the modifier BEFORE the keydown event + ...Object.entries(modifierKeys).map( + ([key, modKey]): behaviorPlugin => ({ + matches: keyDef => keyDef.key === key, + handle: (keyDef, element, options, state) => { + state.modifiers[modKey] = true + }, + }), + ), + + // AltGraph produces an extra keydown for Control + // The modifier does not change + { + matches: keyDef => keyDef.key === 'AltGraph', + handle: (keyDef, element, options, state) => { + const ctrlKeyDef = options.keyboardMap.find( + k => k.key === 'Control', + ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} + fireEvent.keyDown(element, getKeyEventProps(ctrlKeyDef, state)) + }, + }, +] + +export const keydownBehavior: behaviorPlugin[] = [ + { + matches: keyDef => keyDef.key === 'CapsLock', + handle: (keyDef, element, options, state) => { + state.modifiers.caps = !state.modifiers.caps + }, + }, + { + matches: keyDef => keyDef.key === 'Backspace', + handle: (keyDef, element, options, state) => { + const {newValue, newSelectionStart} = calculateNewBackspaceValue( + element, + state.carryValue, + ) + + fireInputEventIfNeeded({ + newValue, + newSelectionStart, + eventOverrides: { + inputType: 'deleteContentBackward', + }, + currentElement: () => element, + }) + + if (state.carryValue) { + state.carryValue = getValue(element) === newValue ? undefined : newValue + } + }, + }, +] + +export const keypressBehavior: behaviorPlugin[] = [ + { + matches: (keyDef, element) => + keyDef.key === 'Enter' && + (isClickableInput(element) || + // Links with href defined should handle Enter the same as a click + (isInstanceOfElement(element, 'HTMLAnchorElement') && + Boolean((element as HTMLAnchorElement).href))), + handle: (keyDef, element, options, state) => { + fireEvent.click(element, getMouseEventProps(state)) + }, + }, + { + matches: (keyDef, element) => + keyDef.key === 'Enter' && + isInstanceOfElement(element, 'HTMLInputElement'), + handle: (keyDef, element) => { + const form = (element as HTMLInputElement).form + + if ( + form && + (form.querySelectorAll('input').length === 1 || + form.querySelector('input[type="submit"]') || + form.querySelector('button[type="submit"]')) + ) { + fireEvent.submit(form) + } + }, + }, +] + +export const preKeyupBehavior: behaviorPlugin[] = [ + // modifierKeys switch off the modifier BEFORE the keyup event + ...Object.entries(modifierKeys).map( + ([key, modKey]): behaviorPlugin => ({ + matches: keyDef => keyDef.key === key, + handle: (keyDef, element, options, state) => { + state.modifiers[modKey] = false + }, + }), + ), +] + +export const keyupBehavior: behaviorPlugin[] = [ + { + matches: (keyDef, element) => + keyDef.key === ' ' && isClickableInput(element), + handle: (keyDef, element, options, state) => { + fireEvent.click(element, getMouseEventProps(state)) + }, + }, +] + +export const postKeyupBehavior: behaviorPlugin[] = [ + // AltGraph produces an extra keyup for Control + // The modifier does not change + { + matches: keyDef => keyDef.key === 'AltGraph', + handle: (keyDef, element, options, state) => { + const ctrlKeyDef = options.keyboardMap.find( + k => k.key === 'Control', + ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} + fireEvent.keyUp(element, getKeyEventProps(ctrlKeyDef, state)) + }, + }, +] diff --git a/src/keyboard/plugins/functional/calculateBackspaceValue.ts b/src/keyboard/plugins/functional/calculateBackspaceValue.ts new file mode 100644 index 00000000..4e544f61 --- /dev/null +++ b/src/keyboard/plugins/functional/calculateBackspaceValue.ts @@ -0,0 +1,43 @@ +import {getSelectionRange, getValue} from '../../../utils' + +// yes, calculateNewBackspaceValue and calculateNewValue look extremely similar +// and you may be tempted to create a shared abstraction. +// If you, brave soul, decide to so endevor, please increment this count +// when you inevitably fail: 1 +export function calculateNewBackspaceValue( + element: Element, + value = getValue(element) ?? /* istanbul ignore next */ '', +) { + const {selectionStart, selectionEnd} = getSelectionRange(element) + + let newValue, newSelectionStart + + if (selectionStart === null) { + // at the end of an input type that does not support selection ranges + // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793 + newValue = value.slice(0, value.length - 1) + + newSelectionStart = newValue.length + } else if (selectionStart === selectionEnd) { + if (selectionStart === 0) { + // at the beginning of the input + newValue = value + newSelectionStart = selectionStart + } else if (selectionStart === value.length) { + // at the end of the input + newValue = value.slice(0, value.length - 1) + newSelectionStart = selectionStart - 1 + } else { + // in the middle of the input + newValue = value.slice(0, selectionStart - 1) + value.slice(selectionEnd) + newSelectionStart = selectionStart - 1 + } + } else { + // we have something selected + const firstPart = value.slice(0, selectionStart) + newValue = firstPart + value.slice(selectionEnd as number) + newSelectionStart = firstPart.length + } + + return {newValue, newSelectionStart} +} diff --git a/src/keyboard/plugins/index.ts b/src/keyboard/plugins/index.ts new file mode 100644 index 00000000..1cfd9380 --- /dev/null +++ b/src/keyboard/plugins/index.ts @@ -0,0 +1,43 @@ +import {behaviorPlugin} from '../types' +import {isInstanceOfElement} from '../../utils' +import * as arrowKeys from './arrow' +import * as controlKeys from './control' +import * as characterKeys from './character' +import * as functionalKeys from './functional' + +export const replaceBehavior: behaviorPlugin[] = [ + { + matches: (keyDef, element) => + keyDef.key === 'selectall' && + (isInstanceOfElement(element, 'HTMLInputElement') || + isInstanceOfElement(element, 'HTMLTextAreaElement')), + handle: (keyDef, element) => { + ;(element as HTMLInputElement).select() + }, + }, +] + +export const preKeydownBehavior: behaviorPlugin[] = [ + ...functionalKeys.preKeydownBehavior, +] + +export const keydownBehavior: behaviorPlugin[] = [ + ...arrowKeys.keydownBehavior, + ...controlKeys.keydownBehavior, + ...functionalKeys.keydownBehavior, +] + +export const keypressBehavior: behaviorPlugin[] = [ + ...functionalKeys.keypressBehavior, + ...characterKeys.keypressBehavior, +] + +export const preKeyupBehavior: behaviorPlugin[] = [ + ...functionalKeys.preKeyupBehavior, +] + +export const keyupBehavior: behaviorPlugin[] = [...functionalKeys.keyupBehavior] + +export const postKeyupBehavior: behaviorPlugin[] = [ + ...functionalKeys.postKeyupBehavior, +] diff --git a/src/keyboard/shared/fireChangeForInputTimeIfValid.ts b/src/keyboard/shared/fireChangeForInputTimeIfValid.ts new file mode 100644 index 00000000..3a567698 --- /dev/null +++ b/src/keyboard/shared/fireChangeForInputTimeIfValid.ts @@ -0,0 +1,12 @@ +import {fireEvent} from '@testing-library/dom' +import {isValidInputTimeValue} from '../../utils' + +export function fireChangeForInputTimeIfValid( + el: HTMLInputElement & {type: 'time'}, + prevValue: unknown, + timeNewEntry: string, +) { + if (isValidInputTimeValue(el, timeNewEntry) && prevValue !== timeNewEntry) { + fireEvent.change(el, {target: {value: timeNewEntry}}) + } +} diff --git a/src/keyboard/shared/fireInputEventIfNeeded.ts b/src/keyboard/shared/fireInputEventIfNeeded.ts new file mode 100644 index 00000000..2c468dc4 --- /dev/null +++ b/src/keyboard/shared/fireInputEventIfNeeded.ts @@ -0,0 +1,63 @@ +import {fireEvent} from '@testing-library/dom' +import { + isInstanceOfElement, + isClickableInput, + getValue, + isContentEditable, +} from '../../utils' +import {setSelectionRange} from './setSelectionRange' + +export function fireInputEventIfNeeded({ + currentElement, + newValue, + newSelectionStart, + eventOverrides, +}: { + currentElement: () => Element | null + newValue: string + newSelectionStart: number + eventOverrides: Partial[1]> & { + [k: string]: unknown + } +}): { + prevValue: string | null +} { + const el = currentElement() + const prevValue = getValue(el) + if ( + el && + !isReadonly(el) && + !isClickableInput(el) && + newValue !== prevValue + ) { + if (isContentEditable(el)) { + fireEvent.input(el, { + target: {textContent: newValue}, + ...eventOverrides, + }) + } else { + fireEvent.input(el, { + target: {value: newValue}, + ...eventOverrides, + }) + } + + setSelectionRange({ + currentElement, + newValue, + newSelectionStart, + }) + } + + return {prevValue} +} + +function isReadonly(element: Element): boolean { + if ( + !isInstanceOfElement(element, 'HTMLInputElement') && + !isInstanceOfElement(element, 'HTMLTextAreaElement') + ) { + return false + } + return (element as HTMLInputElement | HTMLTextAreaElement).readOnly +} diff --git a/src/keyboard/shared/index.ts b/src/keyboard/shared/index.ts new file mode 100644 index 00000000..d1f33cf7 --- /dev/null +++ b/src/keyboard/shared/index.ts @@ -0,0 +1,3 @@ +export * from './fireChangeForInputTimeIfValid' +export * from './fireInputEventIfNeeded' +export * from './setSelectionRange' diff --git a/src/keyboard/shared/setSelectionRange.ts b/src/keyboard/shared/setSelectionRange.ts new file mode 100644 index 00000000..a25ceb2d --- /dev/null +++ b/src/keyboard/shared/setSelectionRange.ts @@ -0,0 +1,30 @@ +import {getValue, setSelectionRangeIfNecessary} from '../../utils' + +export function setSelectionRange({ + currentElement, + newValue, + newSelectionStart, +}: { + currentElement: () => Element | null + newValue: string + newSelectionStart: number +}): void { + // if we *can* change the selection start, then we will if the new value + // is the same as the current value (so it wasn't programatically changed + // when the fireEvent.input was triggered). + // The reason we have to do this at all is because it actually *is* + // programmatically changed by fireEvent.input, so we have to simulate the + // browser's default behavior + const el = currentElement() as Element + + const value = getValue(el) as string + + if (value === newValue) { + setSelectionRangeIfNecessary(el, newSelectionStart, newSelectionStart) + } else { + // If the currentValue is different than the expected newValue and we *can* + // change the selection range, than we should set it to the length of the + // currentValue to ensure that the browser behavior is mimicked. + setSelectionRangeIfNecessary(el, value.length, value.length) + } +} diff --git a/src/keyboard/specialCharMap.ts b/src/keyboard/specialCharMap.ts new file mode 100644 index 00000000..b69ffdc6 --- /dev/null +++ b/src/keyboard/specialCharMap.ts @@ -0,0 +1,15 @@ +export const specialCharMap = { + arrowLeft: '{arrowleft}', + arrowRight: '{arrowright}', + arrowDown: '{arrowdown}', + arrowUp: '{arrowup}', + enter: '{enter}', + escape: '{esc}', + delete: '{del}', + backspace: '{backspace}', + home: '{home}', + end: '{end}', + selectAll: '{selectall}', + space: '{space}', + whitespace: ' ', +} as const diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts new file mode 100644 index 00000000..1fc949d5 --- /dev/null +++ b/src/keyboard/types.ts @@ -0,0 +1,87 @@ +/** + * @internal Do not create/alter this by yourself as this type might be subject to changes. + */ +export type keyboardState = { + /** + All keys that have been pressed and not been lifted up yet. + */ + pressed: {keyDef: keyboardKey; unpreventedDefault: boolean}[] + + /** + Active modifiers + */ + modifiers: { + alt: boolean + caps: boolean + ctrl: boolean + meta: boolean + shift: boolean + } + + /** + The element the keyboard input is performed on. + Some behavior might differ if the activeElement changes between corresponding keyboard events. + */ + activeElement: Element | null + + /** + For HTMLInputElements type='number': + If the last input char is '.', '-' or 'e', + the IDL value attribute does not reflect the input value. + */ + carryValue?: string + + /** + Carry over characters to following key handlers. + E.g. ^1 + */ + carryChar: string +} + +export type keyboardOptions = { + /** Document in which to perform the events */ + document: Document + /** Delay between keystrokes */ + delay: number + /** Add modifiers for given keys - not implemented yet */ + autoModify: boolean + /** Keyboard layout to use */ + keyboardMap: keyboardKey[] +} + +export enum DOM_KEY_LOCATION { + STANDARD = 0, + LEFT = 1, + RIGHT = 2, + NUMPAD = 3, +} + +export interface keyboardKey { + /** Physical location on a keyboard */ + code?: string + /** Character or functional key descriptor */ + key?: string + /** Location on the keyboard for keys with multiple representation */ + location?: DOM_KEY_LOCATION + /** keyCode for legacy support */ + keyCode?: number + /** Does the character in `key` require/imply AltRight to be pressed? */ + altGr?: boolean + /** Does the character in `key` require/imply a shiftKey to be pressed? */ + shift?: boolean +} + +export interface behaviorPlugin { + matches: ( + keyDef: keyboardKey, + element: Element, + options: keyboardOptions, + state: keyboardState, + ) => boolean + handle: ( + keyDef: keyboardKey, + element: Element, + options: keyboardOptions, + state: keyboardState, + ) => void +} diff --git a/src/keys/navigation-key.js b/src/keys/navigation-key.js deleted file mode 100644 index 2bbcce8c..00000000 --- a/src/keys/navigation-key.js +++ /dev/null @@ -1,71 +0,0 @@ -import {fireEvent} from '@testing-library/dom' - -import {setSelectionRangeIfNecessary} from '../utils' - -const keys = { - Home: { - keyCode: 36, - }, - End: { - keyCode: 35, - }, - ArrowLeft: { - keyCode: 37, - }, - ArrowRight: { - keyCode: 39, - }, -} - -function getSelectionRange(currentElement, key) { - const {selectionStart, selectionEnd} = currentElement() - - if (key === 'Home') { - return { - selectionStart: 0, - selectionEnd: 0, - } - } - - if (key === 'End') { - return { - selectionStart: selectionEnd + 1, - selectionEnd: selectionEnd + 1, - } - } - - const cursorChange = Number(key in keys) * (key === 'ArrowLeft' ? -1 : 1) - return { - selectionStart: selectionStart + cursorChange, - selectionEnd: selectionEnd + cursorChange, - } -} - -function navigationKey(key) { - const event = { - key, - keyCode: keys[key].keyCode, - which: keys[key].keyCode, - } - - return ({currentElement, eventOverrides}) => { - fireEvent.keyDown(currentElement(), { - ...event, - ...eventOverrides, - }) - - const range = getSelectionRange(currentElement, key) - setSelectionRangeIfNecessary( - currentElement(), - range.selectionStart, - range.selectionEnd, - ) - - fireEvent.keyUp(currentElement(), { - ...event, - ...eventOverrides, - }) - } -} - -export {navigationKey} diff --git a/src/type.js b/src/type.js deleted file mode 100644 index 125c1968..00000000 --- a/src/type.js +++ /dev/null @@ -1,800 +0,0 @@ -// TODO: wrap in asyncWrapper -import { - fireEvent, - getConfig as getDOMTestingLibraryConfig, -} from '@testing-library/dom' - -import { - getActiveElement, - calculateNewValue, - setSelectionRangeIfNecessary, - isClickableInput, - isValidDateValue, - getSelectionRange, - getValue, - isContentEditable, - isValidInputTimeValue, - buildTimeValue, - isInstanceOfElement, -} from './utils' -import {click} from './click' -import {navigationKey} from './keys/navigation-key' - -const modifierCallbackMap = { - ...createModifierCallbackEntries({ - name: 'shift', - key: 'Shift', - keyCode: 16, - modifierProperty: 'shiftKey', - }), - ...createModifierCallbackEntries({ - name: 'ctrl', - key: 'Control', - keyCode: 17, - modifierProperty: 'ctrlKey', - }), - ...createModifierCallbackEntries({ - name: 'alt', - key: 'Alt', - keyCode: 18, - modifierProperty: 'altKey', - }), - ...createModifierCallbackEntries({ - name: 'meta', - key: 'Meta', - keyCode: 93, - modifierProperty: 'metaKey', - }), - // capslock is inline because of the need to fire both keydown and keyup on use, while preserving the modifier state. - '{capslock}': function capslockOn({currentElement, eventOverrides}) { - const newEventOverrides = {modifierCapsLock: true} - - fireEvent.keyDown(currentElement(), { - key: 'CapsLock', - keyCode: 20, - which: 20, - ...eventOverrides, - ...newEventOverrides, - }) - fireEvent.keyUp(currentElement(), { - key: 'CapsLock', - keyCode: 20, - which: 20, - ...eventOverrides, - ...newEventOverrides, - }) - - return {eventOverrides: newEventOverrides} - }, - '{/capslock}': function capslockOff({currentElement, eventOverrides}) { - const newEventOverrides = {modifierCapsLock: false} - - fireEvent.keyDown(currentElement(), { - key: 'CapsLock', - keyCode: 20, - which: 20, - ...eventOverrides, - ...newEventOverrides, - }) - fireEvent.keyUp(currentElement(), { - key: 'CapsLock', - keyCode: 20, - which: 20, - ...eventOverrides, - ...newEventOverrides, - }) - - return {eventOverrides: newEventOverrides} - }, -} - -const specialCharMap = { - arrowLeft: '{arrowleft}', - arrowRight: '{arrowright}', - arrowDown: '{arrowdown}', - arrowUp: '{arrowup}', - enter: '{enter}', - escape: '{esc}', - delete: '{del}', - backspace: '{backspace}', - home: '{home}', - end: '{end}', - selectAll: '{selectall}', - space: '{space}', - whitespace: ' ', -} - -const specialCharCallbackMap = { - [specialCharMap.arrowLeft]: navigationKey('ArrowLeft'), - [specialCharMap.arrowRight]: navigationKey('ArrowRight'), - [specialCharMap.arrowDown]: handleArrowDown, - [specialCharMap.arrowUp]: handleArrowUp, - [specialCharMap.home]: navigationKey('Home'), - [specialCharMap.end]: navigationKey('End'), - [specialCharMap.enter]: handleEnter, - [specialCharMap.escape]: handleEsc, - [specialCharMap.delete]: handleDel, - [specialCharMap.backspace]: handleBackspace, - [specialCharMap.selectAll]: handleSelectall, - [specialCharMap.space]: handleSpace, - [specialCharMap.whitespace]: handleSpace, -} - -function wait(time) { - return new Promise(resolve => setTimeout(() => resolve(), time)) -} - -// this needs to be wrapped in the event/asyncWrapper for React's act and angular's change detection -// depending on whether it will be async. -async function type(element, text, {delay = 0, ...options} = {}) { - // we do not want to wrap in the asyncWrapper if we're not - // going to actually be doing anything async, so we only wrap - // if the delay is greater than 0 - let result - if (delay > 0) { - await getDOMTestingLibraryConfig().asyncWrapper(async () => { - result = await typeImpl(element, text, {delay, ...options}) - }) - } else { - result = typeImpl(element, text, {delay, ...options}) - } - return result -} - -async function typeImpl( - element, - text, - { - delay, - skipClick = false, - skipAutoClose = false, - initialSelectionStart, - initialSelectionEnd, - }, -) { - if (element.disabled) return - - if (!skipClick) click(element) - - if (isContentEditable(element) && document.getSelection().rangeCount === 0) { - const range = document.createRange() - range.setStart(element, 0) - range.setEnd(element, 0) - document.getSelection().addRange(range) - } - // The focused element could change between each event, so get the currently active element each time - const currentElement = () => getActiveElement(element.ownerDocument) - - // by default, a new element has it's selection start and end at 0 - // but most of the time when people call "type", they expect it to type - // at the end of the current input value. So, if the selection start - // and end are both the default of 0, then we'll go ahead and change - // them to the length of the current value. - // the only time it would make sense to pass the initialSelectionStart or - // initialSelectionEnd is if you have an input with a value and want to - // explicitely start typing with the cursor at 0. Not super common. - const value = getValue(currentElement()) - - const {selectionStart, selectionEnd} = getSelectionRange(element) - - if (value != null && selectionStart === 0 && selectionEnd === 0) { - setSelectionRangeIfNecessary( - currentElement(), - initialSelectionStart ?? value.length, - initialSelectionEnd ?? value.length, - ) - } - - const eventCallbacks = queueCallbacks() - await runCallbacks(eventCallbacks) - - function queueCallbacks() { - const callbacks = [] - let remainingString = text - - while (remainingString) { - const {callback, remainingString: newRemainingString} = getNextCallback( - remainingString, - skipAutoClose, - ) - callbacks.push(callback) - remainingString = newRemainingString - } - - return callbacks - } - - async function runCallbacks(callbacks) { - const eventOverrides = {} - let prevWasMinus, prevWasPeriod, prevValue, typedValue - for (const callback of callbacks) { - if (delay > 0) await wait(delay) - if (!currentElement().disabled) { - const returnValue = callback({ - currentElement, - prevWasMinus, - prevWasPeriod, - prevValue, - eventOverrides, - typedValue, - }) - Object.assign(eventOverrides, returnValue?.eventOverrides) - prevWasMinus = returnValue?.prevWasMinus - prevWasPeriod = returnValue?.prevWasPeriod - prevValue = returnValue?.prevValue - typedValue = returnValue?.typedValue - } - } - } -} - -function getNextCallback(remainingString, skipAutoClose) { - const modifierCallback = getModifierCallback(remainingString, skipAutoClose) - if (modifierCallback) { - return modifierCallback - } - - const specialCharCallback = getSpecialCharCallback(remainingString) - if (specialCharCallback) { - return specialCharCallback - } - - return getTypeCallback(remainingString) -} - -function getModifierCallback(remainingString, skipAutoClose) { - const modifierKey = Object.keys(modifierCallbackMap).find(key => - remainingString.startsWith(key), - ) - if (!modifierKey) { - return null - } - const callback = modifierCallbackMap[modifierKey] - - // if this modifier has an associated "close" callback and the developer - // doesn't close it themselves, then we close it for them automatically - // Effectively if they send in: '{alt}a' then we type: '{alt}a{/alt}' - if ( - !skipAutoClose && - callback.closeName && - !remainingString.includes(callback.closeName) - ) { - remainingString += callback.closeName - } - remainingString = remainingString.slice(modifierKey.length) - return { - callback, - remainingString, - } -} - -function getSpecialCharCallback(remainingString) { - const specialChar = Object.keys(specialCharCallbackMap).find(key => - remainingString.startsWith(key), - ) - if (!specialChar) { - return null - } - return { - callback: specialCharCallbackMap[specialChar], - remainingString: remainingString.slice(specialChar.length), - } -} - -function getTypeCallback(remainingString) { - const character = remainingString[0] - const callback = context => typeCharacter(character, context) - return { - callback, - remainingString: remainingString.slice(1), - } -} - -function setSelectionRange({currentElement, newValue, newSelectionStart}) { - // if we *can* change the selection start, then we will if the new value - // is the same as the current value (so it wasn't programatically changed - // when the fireEvent.input was triggered). - // The reason we have to do this at all is because it actually *is* - // programmatically changed by fireEvent.input, so we have to simulate the - // browser's default behavior - const value = getValue(currentElement()) - - if (value === newValue) { - setSelectionRangeIfNecessary( - currentElement(), - newSelectionStart, - newSelectionStart, - ) - } else { - // If the currentValue is different than the expected newValue and we *can* - // change the selection range, than we should set it to the length of the - // currentValue to ensure that the browser behavior is mimicked. - setSelectionRangeIfNecessary(currentElement(), value.length, value.length) - } -} - -function fireInputEventIfNeeded({ - currentElement, - newValue, - newSelectionStart, - eventOverrides, -}) { - const prevValue = getValue(currentElement()) - if ( - !currentElement().readOnly && - !isClickableInput(currentElement()) && - newValue !== prevValue - ) { - if (isContentEditable(currentElement())) { - fireEvent.input(currentElement(), { - target: {textContent: newValue}, - ...eventOverrides, - }) - } else { - fireEvent.input(currentElement(), { - target: {value: newValue}, - ...eventOverrides, - }) - } - - setSelectionRange({ - currentElement, - newValue, - newSelectionStart, - }) - } - - return {prevValue} -} - -function typeCharacter( - char, - { - currentElement, - prevWasMinus = false, - prevWasPeriod = false, - prevValue = '', - typedValue = '', - eventOverrides, - }, -) { - const key = char // TODO: check if this also valid for characters with diacritic markers e.g. úé etc - const keyCode = char.charCodeAt(0) - let nextPrevWasMinus, nextPrevWasPeriod - const textToBeTyped = typedValue + char - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyDownDefaultNotPrevented) { - const keyPressDefaultNotPrevented = fireEvent.keyPress(currentElement(), { - key, - keyCode, - charCode: keyCode, - ...eventOverrides, - }) - if (getValue(currentElement()) != null && keyPressDefaultNotPrevented) { - let newEntry = char - if (prevWasMinus) { - newEntry = `-${char}` - } else if (prevWasPeriod) { - newEntry = `${prevValue}.${char}` - } - - if (isValidDateValue(currentElement(), textToBeTyped)) { - newEntry = textToBeTyped - } - - const timeNewEntry = buildTimeValue(textToBeTyped) - if (isValidInputTimeValue(currentElement(), timeNewEntry)) { - newEntry = timeNewEntry - } - - const inputEvent = fireInputEventIfNeeded({ - ...calculateNewValue(newEntry, currentElement()), - eventOverrides: { - data: key, - inputType: 'insertText', - ...eventOverrides, - }, - currentElement, - }) - prevValue = inputEvent.prevValue - - if (isValidDateValue(currentElement(), textToBeTyped)) { - fireEvent.change(currentElement(), {target: {value: textToBeTyped}}) - } - - fireChangeForInputTimeIfValid(currentElement, prevValue, timeNewEntry) - - // typing "-" into a number input will not actually update the value - // so for the next character we type, the value should be set to - // `-${newEntry}` - // we also preserve the prevWasMinus when the value is unchanged due - // to typing an invalid character (typing "-a3" results in "-3") - // same applies for the decimal character. - if (currentElement().type === 'number') { - const newValue = getValue(currentElement()) - if (newValue === prevValue && newEntry !== '-') { - nextPrevWasMinus = prevWasMinus - } else { - nextPrevWasMinus = newEntry === '-' - } - if (newValue === prevValue && newEntry !== '.') { - nextPrevWasPeriod = prevWasPeriod - } else { - nextPrevWasPeriod = newEntry === '.' - } - } - } - } - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - return { - prevWasMinus: nextPrevWasMinus, - prevWasPeriod: nextPrevWasPeriod, - prevValue, - typedValue: textToBeTyped, - } -} - -function fireChangeForInputTimeIfValid( - currentElement, - prevValue, - timeNewEntry, -) { - if ( - isValidInputTimeValue(currentElement(), timeNewEntry) && - prevValue !== timeNewEntry - ) { - fireEvent.change(currentElement(), {target: {value: timeNewEntry}}) - } -} - -// yes, calculateNewBackspaceValue and calculateNewValue look extremely similar -// and you may be tempted to create a shared abstraction. -// If you, brave soul, decide to so endevor, please increment this count -// when you inevitably fail: 1 -function calculateNewBackspaceValue(element) { - const {selectionStart, selectionEnd} = getSelectionRange(element) - const value = getValue(element) - let newValue, newSelectionStart - - if (selectionStart === null) { - // at the end of an input type that does not support selection ranges - // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793 - newValue = value.slice(0, value.length - 1) - newSelectionStart = selectionStart - 1 - } else if (selectionStart === selectionEnd) { - if (selectionStart === 0) { - // at the beginning of the input - newValue = value - newSelectionStart = selectionStart - } else if (selectionStart === value.length) { - // at the end of the input - newValue = value.slice(0, value.length - 1) - newSelectionStart = selectionStart - 1 - } else { - // in the middle of the input - newValue = value.slice(0, selectionStart - 1) + value.slice(selectionEnd) - newSelectionStart = selectionStart - 1 - } - } else { - // we have something selected - const firstPart = value.slice(0, selectionStart) - newValue = firstPart + value.slice(selectionEnd) - newSelectionStart = firstPart.length - } - - return {newValue, newSelectionStart} -} - -function calculateNewDeleteValue(element) { - const {selectionStart, selectionEnd} = getSelectionRange(element) - const value = getValue(element) - let newValue - - if (selectionStart === null) { - // at the end of an input type that does not support selection ranges - // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793 - newValue = value - } else if (selectionStart === selectionEnd) { - if (selectionStart === 0) { - // at the beginning of the input - newValue = value.slice(1) - } else if (selectionStart === value.length) { - // at the end of the input - newValue = value - } else { - // in the middle of the input - newValue = value.slice(0, selectionStart) + value.slice(selectionEnd + 1) - } - } else { - // we have something selected - const firstPart = value.slice(0, selectionStart) - newValue = firstPart + value.slice(selectionEnd) - } - - return {newValue, newSelectionStart: selectionStart} -} - -function createModifierCallbackEntries({name, key, keyCode, modifierProperty}) { - const openName = `{${name}}` - const closeName = `{/${name}}` - - function open({currentElement, eventOverrides}) { - const newEventOverrides = {[modifierProperty]: true} - - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - ...newEventOverrides, - }) - - return {eventOverrides: newEventOverrides} - } - open.closeName = closeName - function close({currentElement, eventOverrides}) { - const newEventOverrides = {[modifierProperty]: false} - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - ...newEventOverrides, - }) - - return {eventOverrides: newEventOverrides} - } - return { - [openName]: open, - [closeName]: close, - } -} - -function handleEnter({currentElement, eventOverrides}) { - const key = 'Enter' - const keyCode = 13 - - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyDownDefaultNotPrevented) { - const keyPressDefaultNotPrevented = fireEvent.keyPress(currentElement(), { - key, - keyCode, - charCode: keyCode, - ...eventOverrides, - }) - - if (keyPressDefaultNotPrevented) { - if ( - isClickableInput(currentElement()) || - // Links with href defined should handle Enter the same as a click - (isInstanceOfElement(currentElement(), 'HTMLAnchorElement') && - currentElement().href) - ) { - fireEvent.click(currentElement(), { - ...eventOverrides, - }) - } - - if (currentElement().tagName === 'TEXTAREA') { - const {newValue, newSelectionStart} = calculateNewValue( - '\n', - currentElement(), - ) - fireEvent.input(currentElement(), { - target: {value: newValue}, - inputType: 'insertLineBreak', - ...eventOverrides, - }) - setSelectionRange({ - currentElement, - newValue, - newSelectionStart, - }) - } - - if ( - currentElement().tagName === 'INPUT' && - currentElement().form && - (currentElement().form.querySelectorAll('input').length === 1 || - currentElement().form.querySelector('input[type="submit"]') || - currentElement().form.querySelector('button[type="submit"]')) - ) { - fireEvent.submit(currentElement().form) - } - } - } - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) -} - -function handleEsc({currentElement, eventOverrides}) { - const key = 'Escape' - const keyCode = 27 - - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - // NOTE: Browsers do not fire a keypress on meta key presses - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) -} - -function handleDel({currentElement, eventOverrides}) { - const key = 'Delete' - const keyCode = 46 - - const keyPressDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyPressDefaultNotPrevented) { - fireInputEventIfNeeded({ - ...calculateNewDeleteValue(currentElement()), - eventOverrides: { - inputType: 'deleteContentForward', - ...eventOverrides, - }, - currentElement, - }) - } - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) -} - -function handleBackspace({currentElement, eventOverrides}) { - const key = 'Backspace' - const keyCode = 8 - - const keyPressDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyPressDefaultNotPrevented) { - fireInputEventIfNeeded({ - ...calculateNewBackspaceValue(currentElement()), - eventOverrides: { - inputType: 'deleteContentBackward', - ...eventOverrides, - }, - currentElement, - }) - } - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) -} - -function handleSelectall({currentElement}) { - currentElement().setSelectionRange(0, getValue(currentElement()).length) -} - -function handleSpace(context) { - if (isClickableInput(context.currentElement())) { - handleSpaceOnClickable(context) - return - } - typeCharacter(' ', context) -} - -function handleSpaceOnClickable({currentElement, eventOverrides}) { - const key = ' ' - const keyCode = 32 - - const keyDownDefaultNotPrevented = fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyDownDefaultNotPrevented) { - fireEvent.keyPress(currentElement(), { - key, - keyCode, - charCode: keyCode, - ...eventOverrides, - }) - } - - const keyUpDefaultNotPrevented = fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - if (keyDownDefaultNotPrevented && keyUpDefaultNotPrevented) { - fireEvent.click(currentElement(), { - ...eventOverrides, - }) - } -} - -function handleArrowDown({currentElement, eventOverrides}) { - const key = 'ArrowDown' - const keyCode = 40 - - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) -} - -function handleArrowUp({currentElement, eventOverrides}) { - const key = 'ArrowUp' - const keyCode = 38 - - fireEvent.keyDown(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) - - fireEvent.keyUp(currentElement(), { - key, - keyCode, - which: keyCode, - ...eventOverrides, - }) -} - -export {type, specialCharMap} diff --git a/src/type/index.ts b/src/type/index.ts new file mode 100644 index 00000000..74c99edd --- /dev/null +++ b/src/type/index.ts @@ -0,0 +1,34 @@ +import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {typeImplementation, typeOptions} from './typeImplementation' + +export function type( + element: Element, + text: string, + options?: typeOptions & {delay?: 0}, +): void +export function type( + element: Element, + text: string, + options: typeOptions & {delay: number}, +): Promise +// this needs to be wrapped in the event/asyncWrapper for React's act and angular's change detection +// depending on whether it will be async. +export function type( + element: Element, + text: string, + {delay = 0, ...options}: typeOptions = {}, +): Promise | void { + // we do not want to wrap in the asyncWrapper if we're not + // going to actually be doing anything async, so we only wrap + // if the delay is greater than 0 + + if (delay > 0) { + return getDOMTestingLibraryConfig().asyncWrapper(() => + typeImplementation(element, text, {delay, ...options}), + ) + } else { + return void typeImplementation(element, text, {delay, ...options}) + // prevents users from dealing with UnhandledPromiseRejectionWarning + .catch(console.error) + } +} diff --git a/src/type/typeImplementation.ts b/src/type/typeImplementation.ts new file mode 100644 index 00000000..864e8d71 --- /dev/null +++ b/src/type/typeImplementation.ts @@ -0,0 +1,81 @@ +import { + setSelectionRangeIfNecessary, + getSelectionRange, + getValue, + isContentEditable, + getActiveElement, +} from '../utils' +import {click} from '../click' +import {keyboardImplementationWrapper} from '../keyboard' + +export interface typeOptions { + delay?: number + skipClick?: boolean + skipAutoClose?: boolean + initialSelectionStart?: number + initialSelectionEnd?: number +} + +export async function typeImplementation( + element: Element, + text: string, + { + delay, + skipClick = false, + skipAutoClose = false, + initialSelectionStart = undefined, + initialSelectionEnd = undefined, + }: typeOptions & {delay: number}, +): Promise { + // TODO: properly type guard + // we use this workaround for now to prevent changing behavior + if ((element as {disabled?: boolean}).disabled) return + + if (!skipClick) click(element) + + if (isContentEditable(element)) { + const selection = document.getSelection() + // istanbul ignore else + if (selection && selection.rangeCount === 0) { + const range = document.createRange() + range.setStart(element, 0) + range.setEnd(element, 0) + selection.addRange(range) + } + } + // The focused element could change between each event, so get the currently active element each time + const currentElement = () => getActiveElement(element.ownerDocument) + + // by default, a new element has it's selection start and end at 0 + // but most of the time when people call "type", they expect it to type + // at the end of the current input value. So, if the selection start + // and end are both the default of 0, then we'll go ahead and change + // them to the length of the current value. + // the only time it would make sense to pass the initialSelectionStart or + // initialSelectionEnd is if you have an input with a value and want to + // explicitely start typing with the cursor at 0. Not super common. + const value = getValue(currentElement()) + + const {selectionStart, selectionEnd} = getSelectionRange(element) + + if (value != null && selectionStart === 0 && selectionEnd === 0) { + setSelectionRangeIfNecessary( + currentElement() as Element, + initialSelectionStart ?? value.length, + initialSelectionEnd ?? value.length, + ) + } + + const {promise, releaseAllKeys} = keyboardImplementationWrapper(text, { + delay, + document: element.ownerDocument, + }) + + if (delay > 0) { + await promise + } + + if (!skipAutoClose) { + releaseAllKeys() + } +} diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index b4825948..00000000 --- a/src/utils.js +++ /dev/null @@ -1,384 +0,0 @@ -import {getConfig} from '@testing-library/dom' -import {getWindowFromNode} from '@testing-library/dom/dist/helpers' - -// isInstanceOfElement can be removed once the peerDependency for @testing-library/dom is bumped to a version that includes https://github.com/testing-library/dom-testing-library/pull/885 -/** - * Check if an element is of a given type. - * - * @param {Element} element The element to test - * @param {string} elementType Constructor name. E.g. 'HTMLSelectElement' - */ -function isInstanceOfElement(element, elementType) { - try { - const window = getWindowFromNode(element) - // Window usually has the element constructors as properties but is not required to do so per specs - if (typeof window[elementType] === 'function') { - return element instanceof window[elementType] - } - } catch (e) { - // The document might not be associated with a window - } - - // Fall back to the constructor name as workaround for test environments that - // a) not associate the document with a window - // b) not provide the constructor as property of window - if (/^HTML(\w+)Element$/.test(element.constructor.name)) { - return element.constructor.name === elementType - } - - // The user passed some node that is not created in a browser-like environment - throw new Error( - `Unable to verify if element is instance of ${elementType}. Please file an issue describing your test environment: https://github.com/testing-library/dom-testing-library/issues/new`, - ) -} - -function isMousePressEvent(event) { - return ( - event === 'mousedown' || - event === 'mouseup' || - event === 'click' || - event === 'dblclick' - ) -} - -function invert(map) { - const res = {} - for (const key of Object.keys(map)) { - res[map[key]] = key - } - - return res -} - -// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons -const BUTTONS_TO_NAMES = { - 0: 'none', - 1: 'primary', - 2: 'secondary', - 4: 'auxiliary', -} -const NAMES_TO_BUTTONS = invert(BUTTONS_TO_NAMES) - -// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button -const BUTTON_TO_NAMES = { - 0: 'primary', - 1: 'auxiliary', - 2: 'secondary', -} - -const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES) - -function convertMouseButtons(event, init, property, mapping) { - if (!isMousePressEvent(event)) { - return 0 - } - - if (init[property] != null) { - return init[property] - } - - if (init.buttons != null) { - // not sure how to test this. Feel free to try and add a test if you want. - // istanbul ignore next - return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0 - } - - if (init.button != null) { - // not sure how to test this. Feel free to try and add a test if you want. - // istanbul ignore next - return mapping[BUTTON_TO_NAMES[init.button]] || 0 - } - - return property != 'button' && isMousePressEvent(event) ? 1 : 0 -} - -function getMouseEventOptions(event, init, clickCount = 0) { - init = init || {} - return { - ...init, - // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail - detail: - event === 'mousedown' || event === 'mouseup' || event === 'click' - ? 1 + clickCount - : clickCount, - buttons: convertMouseButtons(event, init, 'buttons', NAMES_TO_BUTTONS), - button: convertMouseButtons(event, init, 'button', NAMES_TO_BUTTON), - } -} - -// Absolutely NO events fire on label elements that contain their control -// if that control is disabled. NUTS! -// no joke. There are NO events for: