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: