Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugix/fire type events on active element #299

62 changes: 61 additions & 1 deletion src/__tests__/type.js
@@ -1,4 +1,4 @@
import React from 'react'
import React, {Fragment} from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '../../src'

Expand Down Expand Up @@ -256,3 +256,63 @@ test.each(['input', 'textarea'])(
expect(onKeyUp).not.toHaveBeenCalled()
},
)

test('should fire events on the currently focussed element', async () => {
kayleighridd marked this conversation as resolved.
Show resolved Hide resolved
const changeFocusLimit = 7
const onKeyDown = jest.fn(event => {
if (event.target.value.length === changeFocusLimit) {
screen.getByTestId('input2').focus()
}
})

render(
<Fragment>
<input data-testid="input1" onKeyDown={onKeyDown} />
<input data-testid="input2" />
</Fragment>,
)

const text = 'Hello, world!'

const input1 = screen.getByTestId('input1')
const input2 = screen.getByTestId('input2')

await userEvent.type(input1, text)

expect(input1).toHaveValue(text.slice(0, changeFocusLimit))
expect(input2).toHaveValue(text.slice(changeFocusLimit))
expect(input2).toHaveFocus()
})

test('should enter text up to maxLength of the current element if provided', async () => {
const changeFocusLimit = 7
const input2MaxLength = 2

const onKeyDown = jest.fn(event => {
if (event.target.value.length === changeFocusLimit) {
screen.getByTestId('input2').focus()
}
})

render(
<>
<input data-testid="input" onKeyDown={onKeyDown} />
<input data-testid="input2" maxLength={input2MaxLength} />
</>,
)

const text = 'Hello, world!'
const input2ExpectedValue = text.slice(
changeFocusLimit,
changeFocusLimit + input2MaxLength,
)

const input1 = screen.getByTestId('input')
const input2 = screen.getByTestId('input2')

await userEvent.type(input1, text)

expect(input1).toHaveValue(text.slice(0, changeFocusLimit))
expect(input2).toHaveValue(input2ExpectedValue)
expect(input2).toHaveFocus()
})
122 changes: 80 additions & 42 deletions src/index.js
Expand Up @@ -5,65 +5,73 @@ function wait(time) {
}

function isMousePressEvent(event) {
return event === 'mousedown' || event === 'mouseup' || event === 'click' || event === 'dblclick';
return (
event === 'mousedown' ||
event === 'mouseup' ||
event === 'click' ||
event === 'dblclick'
)
}

function invert(map) {
const res = {};
const res = {}
for (const key of Object.keys(map)) {
res[map[key]] = key;
res[map[key]] = key
}

return res;
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);
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'
};
2: 'secondary',
}

const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES);
const NAMES_TO_BUTTON = invert(BUTTON_TO_NAMES)

function convertMouseButtons(event, init, property, mapping) {
if (!isMousePressEvent(event)) {
return 0;
return 0
}

if (init[property] != null) {
return init[property];
return init[property]
}

if (init.buttons != null) {
return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0;
return mapping[BUTTONS_TO_NAMES[init.buttons]] || 0
}

if (init.button != null) {
return mapping[BUTTON_TO_NAMES[init.button]] || 0;
return mapping[BUTTON_TO_NAMES[init.button]] || 0
}

return property != 'button' && isMousePressEvent(event) ? 1 : 0;
return property != 'button' && isMousePressEvent(event) ? 1 : 0
}

function getMouseEventOptions(event, init, clickCount = 0) {
init = init || {};
init = init || {}
return {
...init,
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail
detail: event === 'mousedown' || event === 'mouseup' ? 1 + clickCount : clickCount,
detail:
event === 'mousedown' || event === 'mouseup'
? 1 + clickCount
: clickCount,
buttons: convertMouseButtons(event, init, 'buttons', NAMES_TO_BUTTONS),
button: convertMouseButtons(event, init, 'button', NAMES_TO_BUTTON),
};
}
}

function clickLabel(label, init) {
Expand Down Expand Up @@ -93,7 +101,10 @@ function clickBooleanElement(element, init) {
function clickElement(element, previousElement, init) {
fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init))
fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init))
const continueDefaultHandling = fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init))
const continueDefaultHandling = fireEvent.mouseDown(
element,
getMouseEventOptions('mousedown', init),
)
const shouldFocus = element.ownerDocument.activeElement !== element
if (continueDefaultHandling) {
if (previousElement) previousElement.blur()
Expand All @@ -108,7 +119,10 @@ function clickElement(element, previousElement, init) {
function dblClickElement(element, previousElement, init) {
fireEvent.mouseOver(element, getMouseEventOptions('mouseover', init))
fireEvent.mouseMove(element, getMouseEventOptions('mousemove', init))
const continueDefaultHandling = fireEvent.mouseDown(element, getMouseEventOptions('mousedown', init))
const continueDefaultHandling = fireEvent.mouseDown(
element,
getMouseEventOptions('mousedown', init),
)
const shouldFocus = element.ownerDocument.activeElement !== element
if (continueDefaultHandling) {
if (previousElement) previousElement.blur()
Expand Down Expand Up @@ -220,8 +234,14 @@ function getPreviouslyFocusedElement(element) {
function click(element, init) {
const previouslyFocusedElement = getPreviouslyFocusedElement(element)
if (previouslyFocusedElement) {
fireEvent.mouseMove(previouslyFocusedElement, getMouseEventOptions('mousemove', init))
fireEvent.mouseLeave(previouslyFocusedElement, getMouseEventOptions('mouseleave', init))
fireEvent.mouseMove(
previouslyFocusedElement,
getMouseEventOptions('mousemove', init),
)
fireEvent.mouseLeave(
previouslyFocusedElement,
getMouseEventOptions('mouseleave', init),
)
}

switch (element.tagName) {
Expand All @@ -242,8 +262,14 @@ function click(element, init) {
function dblClick(element, init) {
const previouslyFocusedElement = getPreviouslyFocusedElement(element)
if (previouslyFocusedElement) {
fireEvent.mouseMove(previouslyFocusedElement, getMouseEventOptions('mousemove', init))
fireEvent.mouseLeave(previouslyFocusedElement, getMouseEventOptions('mouseleave', init))
fireEvent.mouseMove(
previouslyFocusedElement,
getMouseEventOptions('mousemove', init),
)
fireEvent.mouseLeave(
previouslyFocusedElement,
getMouseEventOptions('mouseleave', init),
)
}

switch (element.tagName) {
Expand All @@ -261,16 +287,22 @@ function dblClick(element, init) {
function selectOptions(element, values, init) {
const previouslyFocusedElement = getPreviouslyFocusedElement(element)
if (previouslyFocusedElement) {
fireEvent.mouseMove(previouslyFocusedElement, getMouseEventOptions('mousemove', init))
fireEvent.mouseLeave(previouslyFocusedElement, getMouseEventOptions('mouseleave', init))
fireEvent.mouseMove(
previouslyFocusedElement,
getMouseEventOptions('mousemove', init),
)
fireEvent.mouseLeave(
previouslyFocusedElement,
getMouseEventOptions('mouseleave', init),
)
}

clickElement(element, previouslyFocusedElement, init)

const valArray = Array.isArray(values) ? values : [values]
const selectedOptions = Array.from(
element.querySelectorAll('option'),
).filter(opt => valArray.includes(opt.value) || valArray.includes(opt))
const selectedOptions = Array.from(element.querySelectorAll('option')).filter(
opt => valArray.includes(opt.value) || valArray.includes(opt),
)

if (selectedOptions.length > 0) {
if (element.multiple) {
Expand All @@ -290,21 +322,27 @@ function clear(element) {

async function type(element, text, {allAtOnce = false, delay} = {}) {
if (element.disabled) return
kayleighridd marked this conversation as resolved.
Show resolved Hide resolved
const previousText = element.value

const computedText =
element.maxLength > 0
? text.slice(0, Math.max(element.maxLength - previousText.length, 0))
: text
element.focus()

if (allAtOnce) {
if (!element.readOnly) {
const previousText = element.value

const computedText =
element.maxLength > 0
? text.slice(0, Math.max(element.maxLength - previousText.length, 0))
: text

fireEvent.input(element, {
target: {value: previousText + computedText},
})
}
} else {
let actuallyTyped = previousText
// The focussed element could change between each event, so get the currently active element each time
const currentElement = () => document.activeElement
const actuallyTyped = () => document.activeElement.value
kayleighridd marked this conversation as resolved.
Show resolved Hide resolved

for (let index = 0; index < text.length; index++) {
const char = text[index]
const key = char // TODO: check if this also valid for characters with diacritic markers e.g. úé etc
Expand All @@ -313,28 +351,28 @@ async function type(element, text, {allAtOnce = false, delay} = {}) {
// eslint-disable-next-line no-await-in-loop
if (delay > 0) await wait(delay)

const downEvent = fireEvent.keyDown(element, {
const downEvent = fireEvent.keyDown(currentElement(), {
key,
keyCode,
which: keyCode,
})

if (downEvent) {
const pressEvent = fireEvent.keyPress(element, {
const pressEvent = fireEvent.keyPress(currentElement(), {
key,
keyCode,
charCode: keyCode,
})

const isTextPastThreshold =
(actuallyTyped + key).length > (previousText + computedText).length
(actuallyTyped() + key).length >
(currentElement().maxLength || text.length)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what I'd prefer is to turn computedText into a function called computeText which would be used in the sync case as well as here. I think it'd be something like:

      const computeText = () =>
        currentElement().maxLength > 0
          ? text.slice(0, Math.max(currentElement().maxLength - actuallyTyped().length, 0))
          : text

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated this to the way I think it should work, but I think I may have misunderstood.
The way I understand this is....
Previously we could calculate how many total characters we can type based on the value that was already in the input before we started using previousText. We could then use this as a check against what we've actuallyTyped.
Now we are using the current value (actuallyTyped) of the element (because the previousValue may no longer apply to the current element), so we're computing how many more characters we can add at any point (not total).

I had originally assumed the isTextPastThreshold check would be:

const isTextPastThreshold =
          (currentValue() + key).length >
          (currentValue() + computeText()).length

But this felt wrong, because we're checking how many more characters we can add and then adding it to the current value and that doesn't necessarily mean it's the correct length.

So, I think what I've done is correct, but let me know if I've completely missed the point 😄
(Also, to note, where I'm saying actuallyTyped I've renamed it to currentValue in the code because this felt more reflective of the actual usage now)


if (pressEvent && !isTextPastThreshold) {
actuallyTyped += key
if (!element.readOnly) {
fireEvent.input(element, {
fireEvent.input(currentElement(), {
target: {
value: actuallyTyped,
value: actuallyTyped() + key,
},
bubbles: true,
cancelable: true,
Expand All @@ -343,7 +381,7 @@ async function type(element, text, {allAtOnce = false, delay} = {}) {
}
}

fireEvent.keyUp(element, {
fireEvent.keyUp(currentElement(), {
key,
keyCode,
which: keyCode,
Expand Down