Skip to content

Commit a44b14a

Browse files
authored
fix(type): ensure the selectionStart/End are consistent with browsers (testing-library#322)
Closes: testing-library#321 Closes: testing-library#318 Closes: testing-library#316
1 parent b62dc68 commit a44b14a

File tree

4 files changed

+112
-16
lines changed

4 files changed

+112
-16
lines changed

src/__tests__/type-modifiers.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ test('{backspace} deletes the selected range', async () => {
117117
`)
118118
})
119119

120+
test('{backspace} on an input type that does not support selection ranges', async () => {
121+
const {element} = setup(<input type="email" defaultValue="yo@example.com" />)
122+
// note: you cannot even call setSelectionRange on these kinds of elements...
123+
await userEvent.type(element, '{backspace}')
124+
// removed "m"
125+
expect(element).toHaveValue('yo@example.co')
126+
})
127+
120128
test('{alt}a{/alt}', async () => {
121129
const {element: input, getEventCalls} = setup(<input />)
122130

src/__tests__/type.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,3 +521,53 @@ test('ignored {backspace} in controlled input', async () => {
521521
keyup: 4 (52)
522522
`)
523523
})
524+
525+
// https://github.com/testing-library/user-event/issues/321
526+
test('typing in a textarea with existing text', async () => {
527+
const {element, getEventCalls} = setup(<textarea defaultValue="Hello, " />)
528+
529+
await userEvent.type(element, '12')
530+
expect(getEventCalls()).toMatchInlineSnapshot(`
531+
focus
532+
keydown: 1 (49)
533+
keypress: 1 (49)
534+
input: "Hello, {CURSOR}" -> "Hello, 1"
535+
keyup: 1 (49)
536+
keydown: 2 (50)
537+
keypress: 2 (50)
538+
input: "Hello, 1{CURSOR}" -> "Hello, 12"
539+
keyup: 2 (50)
540+
`)
541+
expect(element).toHaveValue('Hello, 12')
542+
})
543+
544+
// https://github.com/testing-library/user-event/issues/321
545+
test('accepts an initialSelectionStart and initialSelectionEnd', async () => {
546+
const {element, getEventCalls} = setup(<textarea defaultValue="Hello, " />)
547+
element.setSelectionRange(0, 0)
548+
549+
await userEvent.type(element, '12', {
550+
initialSelectionStart: element.selectionStart,
551+
initialSelectionEnd: element.selectionEnd,
552+
})
553+
expect(getEventCalls()).toMatchInlineSnapshot(`
554+
focus
555+
keydown: 1 (49)
556+
keypress: 1 (49)
557+
input: "{CURSOR}Hello, " -> "1Hello, "
558+
keyup: 1 (49)
559+
keydown: 2 (50)
560+
keypress: 2 (50)
561+
input: "1{CURSOR}Hello, " -> "12Hello, "
562+
keyup: 2 (50)
563+
`)
564+
expect(element).toHaveValue('12Hello, ')
565+
})
566+
567+
// https://github.com/testing-library/user-event/issues/316#issuecomment-640199908
568+
test('can type into an input with type `email`', async () => {
569+
const {element} = setup(<input type="email" />)
570+
const email = 'yo@example.com'
571+
await userEvent.type(element, email)
572+
expect(element).toHaveValue(email)
573+
})

src/type.js

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,59 @@ const getActiveElement = document => {
2626
}
2727
}
2828

29-
async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
29+
// eslint-disable-next-line complexity
30+
async function typeImpl(
31+
element,
32+
text,
33+
{allAtOnce = false, delay, initialSelectionStart, initialSelectionEnd} = {},
34+
) {
3035
if (element.disabled) return
3136

3237
element.focus()
3338

3439
// The focused element could change between each event, so get the currently active element each time
3540
const currentElement = () => getActiveElement(element.ownerDocument)
3641
const currentValue = () => currentElement().value
37-
const setSelectionRange = newSelectionStart => {
38-
// if the actual selection start is different from the one we expected
39-
// then we set it to the end of the input
40-
if (currentElement().selectionStart !== newSelectionStart) {
41-
currentElement().setSelectionRange?.(
42-
currentValue().length,
43-
currentValue().length,
44-
)
42+
const setSelectionRange = ({newValue, newSelectionStart}) => {
43+
// if we *can* change the selection start, then we will if the new value
44+
// is the same as the current value (so it wasn't programatically changed
45+
// when the fireEvent.input was triggered).
46+
// The reason we have to do this at all is because it actually *is*
47+
// programmatically changed by fireEvent.input, so we have to simulate the
48+
// browser's default behavior
49+
if (
50+
currentElement().selectionStart !== null &&
51+
currentValue() === newValue
52+
) {
53+
currentElement().setSelectionRange?.(newSelectionStart, newSelectionStart)
4554
}
4655
}
4756

57+
// by default, a new element has it's selection start and end at 0
58+
// but most of the time when people call "type", they expect it to type
59+
// at the end of the current input value. So, if the selection start
60+
// and end are both the default of 0, then we'll go ahead and change
61+
// them to the length of the current value.
62+
// the only time it would make sense to pass the initialSelectionStart or
63+
// initialSelectionEnd is if you have an input with a value and want to
64+
// explicitely start typing with the cursor at 0. Not super common.
65+
if (
66+
currentElement().selectionStart === 0 &&
67+
currentElement().selectionEnd === 0
68+
) {
69+
currentElement().setSelectionRange(
70+
initialSelectionStart ?? currentValue()?.length ?? 0,
71+
initialSelectionEnd ?? currentValue()?.length ?? 0,
72+
)
73+
}
74+
4875
if (allAtOnce) {
4976
if (!element.readOnly) {
5077
const {newValue, newSelectionStart} = calculateNewValue(text)
5178
fireEvent.input(element, {
5279
target: {value: newValue},
5380
})
54-
setSelectionRange(newSelectionStart)
81+
setSelectionRange({newValue, newSelectionStart})
5582
}
5683
} else {
5784
const eventCallbackMap = {
@@ -116,7 +143,7 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
116143
inputType: 'insertLineBreak',
117144
...eventOverrides,
118145
})
119-
setSelectionRange(newSelectionStart)
146+
setSelectionRange({newValue, newSelectionStart})
120147
}
121148

122149
await tick()
@@ -222,7 +249,7 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
222249
...eventOverrides,
223250
})
224251

225-
setSelectionRange(newSelectionStart)
252+
setSelectionRange({newValue, newSelectionStart})
226253
}
227254
}
228255

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

238-
if (selectionStart === selectionEnd) {
265+
if (selectionStart === null) {
266+
// at the end of an input type that does not support selection ranges
267+
// https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
268+
newValue = value.slice(0, value.length - 1)
269+
newSelectionStart = selectionStart - 1
270+
} else if (selectionStart === selectionEnd) {
239271
if (selectionStart === 0) {
240272
// at the beginning of the input
241273
newValue = value
@@ -267,7 +299,11 @@ async function typeImpl(element, text, {allAtOnce = false, delay} = {}) {
267299
const value = currentValue()
268300
let newValue, newSelectionStart
269301

270-
if (selectionStart === selectionEnd) {
302+
if (selectionStart === null) {
303+
// at the end of an input type that does not support selection ranges
304+
// https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
305+
newValue = value + newEntry
306+
} else if (selectionStart === selectionEnd) {
271307
if (selectionStart === 0) {
272308
// at the beginning of the input
273309
newValue = newEntry + value

typings/index.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Definitions by: Wu Haotian <https://github.com/whtsky>
2-
export interface IUserOptions {
2+
export interface ITypeOpts {
33
allAtOnce?: boolean
44
delay?: number
5+
initialSelectionStart?: number
6+
initialSelectionEnd?: number
57
}
68

79
export interface ITabUserOptions {
@@ -40,7 +42,7 @@ declare const userEvent: {
4042
type: (
4143
element: TargetElement,
4244
text: string,
43-
userOpts?: IUserOptions,
45+
userOpts?: ITypeOpts,
4446
) => Promise<void>
4547
tab: (userOpts?: ITabUserOptions) => void
4648
}

0 commit comments

Comments
 (0)