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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(type): ensure the selectionStart/End are consistent with browsers #322

Merged
merged 3 commits into from Jun 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/__tests__/type-modifiers.js
Expand Up @@ -117,6 +117,14 @@ test('{backspace} deletes the selected range', async () => {
`)
})

test('{backspace} on an input type that does not support selection ranges', async () => {
const {element} = setup(<input type="email" defaultValue="yo@example.com" />)
// note: you cannot even call setSelectionRange on these kinds of elements...
await userEvent.type(element, '{backspace}')
// removed "m"
expect(element).toHaveValue('yo@example.co')
})

test('{alt}a{/alt}', async () => {
const {element: input, getEventCalls} = setup(<input />)

Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/type.js
Expand Up @@ -521,3 +521,53 @@ test('ignored {backspace} in controlled input', async () => {
keyup: 4 (52)
`)
})

// https://github.com/testing-library/user-event/issues/321
test('typing in a textarea with existing text', async () => {
const {element, getEventCalls} = setup(<textarea defaultValue="Hello, " />)

await userEvent.type(element, '12')
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: 1 (49)
keypress: 1 (49)
input: "Hello, {CURSOR}" -> "Hello, 1"
keyup: 1 (49)
keydown: 2 (50)
keypress: 2 (50)
input: "Hello, 1{CURSOR}" -> "Hello, 12"
keyup: 2 (50)
`)
expect(element).toHaveValue('Hello, 12')
})

// https://github.com/testing-library/user-event/issues/321
test('accepts an initialSelectionStart and initialSelectionEnd', async () => {
const {element, getEventCalls} = setup(<textarea defaultValue="Hello, " />)
element.setSelectionRange(0, 0)

await userEvent.type(element, '12', {
initialSelectionStart: element.selectionStart,
initialSelectionEnd: element.selectionEnd,
})
expect(getEventCalls()).toMatchInlineSnapshot(`
focus
keydown: 1 (49)
keypress: 1 (49)
input: "{CURSOR}Hello, " -> "1Hello, "
keyup: 1 (49)
keydown: 2 (50)
keypress: 2 (50)
input: "1{CURSOR}Hello, " -> "12Hello, "
keyup: 2 (50)
`)
expect(element).toHaveValue('12Hello, ')
})

// https://github.com/testing-library/user-event/issues/316#issuecomment-640199908
test('can type into an input with type `email`', async () => {
const {element} = setup(<input type="email" />)
const email = 'yo@example.com'
await userEvent.type(element, email)
expect(element).toHaveValue(email)
})
64 changes: 50 additions & 14 deletions src/type.js
Expand Up @@ -26,32 +26,59 @@ const getActiveElement = document => {
}
}

async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
// eslint-disable-next-line complexity
async function typeImpl(
element,
text,
{allAtOnce = false, delay, initialSelectionStart, initialSelectionEnd} = {},
) {
if (element.disabled) return

element.focus()

// The focused element could change between each event, so get the currently active element each time
const currentElement = () => getActiveElement(element.ownerDocument)
const currentValue = () => currentElement().value
const setSelectionRange = newSelectionStart => {
// if the actual selection start is different from the one we expected
// then we set it to the end of the input
if (currentElement().selectionStart !== newSelectionStart) {
currentElement().setSelectionRange?.(
currentValue().length,
currentValue().length,
)
const setSelectionRange = ({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
if (
currentElement().selectionStart !== null &&
currentValue() === newValue
) {
currentElement().setSelectionRange?.(newSelectionStart, newSelectionStart)
}
}

// 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.
if (
currentElement().selectionStart === 0 &&
currentElement().selectionEnd === 0
) {
currentElement().setSelectionRange(
initialSelectionStart ?? currentValue()?.length ?? 0,
initialSelectionEnd ?? currentValue()?.length ?? 0,
)
}

if (allAtOnce) {
if (!element.readOnly) {
const {newValue, newSelectionStart} = calculateNewValue(text)
fireEvent.input(element, {
target: {value: newValue},
})
setSelectionRange(newSelectionStart)
setSelectionRange({newValue, newSelectionStart})
}
} else {
const eventCallbackMap = {
Expand Down Expand Up @@ -116,7 +143,7 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
inputType: 'insertLineBreak',
...eventOverrides,
})
setSelectionRange(newSelectionStart)
setSelectionRange({newValue, newSelectionStart})
}

await tick()
Expand Down Expand Up @@ -222,7 +249,7 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
...eventOverrides,
})

setSelectionRange(newSelectionStart)
setSelectionRange({newValue, newSelectionStart})
}
}

Expand All @@ -235,7 +262,12 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
const value = currentValue()
let newValue, newSelectionStart

if (selectionStart === selectionEnd) {
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
Expand Down Expand Up @@ -267,7 +299,11 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
const value = currentValue()
let newValue, newSelectionStart

if (selectionStart === selectionEnd) {
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 + newEntry
} else if (selectionStart === selectionEnd) {
if (selectionStart === 0) {
// at the beginning of the input
newValue = newEntry + value
Expand Down
6 changes: 4 additions & 2 deletions typings/index.d.ts
@@ -1,7 +1,9 @@
// Definitions by: Wu Haotian <https://github.com/whtsky>
export interface IUserOptions {
export interface ITypeOpts {
allAtOnce?: boolean
delay?: number
initialSelectionStart?: number
initialSelectionEnd?: number
}

export interface ITabUserOptions {
Expand Down Expand Up @@ -40,7 +42,7 @@ declare const userEvent: {
type: (
element: TargetElement,
text: string,
userOpts?: IUserOptions,
userOpts?: ITypeOpts,
) => Promise<void>
tab: (userOpts?: ITabUserOptions) => void
}
Expand Down