Skip to content

Commit b542540

Browse files
authored
feat!: rewrite userEvent.clear API (#779)
BREAKING CHANGE: An error is thrown when calling `userEvent.clear` on an element which is not editable. BREAKING CHANGE: An error is thrown when event handlers prevent `userEvent.clear` from focussing/selecting content.
1 parent 8de88f4 commit b542540

File tree

11 files changed

+279
-227
lines changed

11 files changed

+279
-227
lines changed

src/clear.ts

+20-25
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,32 @@
1-
import {isDisabled, isElementType} from './utils'
1+
import {prepareDocument} from './document'
22
import type {UserEvent} from './setup'
3+
import {
4+
focus,
5+
isAllSelected,
6+
isDisabled,
7+
isEditable,
8+
prepareInput,
9+
selectAll,
10+
} from './utils'
311

412
export function clear(this: UserEvent, element: Element) {
5-
if (!isElementType(element, ['input', 'textarea'])) {
6-
// TODO: support contenteditable
7-
throw new Error(
8-
'clear currently only supports input and textarea elements.',
9-
)
13+
if (!isEditable(element) || isDisabled(element)) {
14+
throw new Error('clear()` is only supported on editable elements.')
1015
}
1116

12-
if (isDisabled(element)) {
13-
return
14-
}
15-
16-
// TODO: track the selection range ourselves so we don't have to do this input "type" trickery
17-
// just like cypress does: https://github.com/cypress-io/cypress/blob/8d7f1a0bedc3c45a2ebf1ff50324b34129fdc683/packages/driver/src/dom/selection.ts#L16-L37
17+
prepareDocument(element.ownerDocument)
1818

19-
const elementType = element.type
19+
focus(element)
2020

21-
if (elementType !== 'textarea') {
22-
// setSelectionRange is not supported on certain types of inputs, e.g. "number" or "email"
23-
;(element as HTMLInputElement).type = 'text'
21+
if (element.ownerDocument.activeElement !== element) {
22+
throw new Error('The element to be cleared could not be focused.')
2423
}
2524

26-
this.type(element, '{selectall}{del}', {
27-
delay: 0,
28-
initialSelectionStart:
29-
element.selectionStart ?? /* istanbul ignore next */ undefined,
30-
initialSelectionEnd:
31-
element.selectionEnd ?? /* istanbul ignore next */ undefined,
32-
})
25+
selectAll(element)
3326

34-
if (elementType !== 'textarea') {
35-
;(element as HTMLInputElement).type = elementType
27+
if (!isAllSelected(element)) {
28+
throw new Error('The element content to be cleared could not be selected.')
3629
}
30+
31+
prepareInput('', element, 'deleteContentBackward')?.commit()
3732
}

src/keyboard/plugins/character.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {behaviorPlugin} from '../types'
88
import {
99
buildTimeValue,
1010
calculateNewValue,
11-
fireInputEvent,
11+
editInputElement,
1212
getInputRange,
1313
getSpaceUntilMaxLength,
1414
getValue,
@@ -50,7 +50,7 @@ export const keypressBehavior: behaviorPlugin[] = [
5050
// this check was provided by fireInputEventIfNeeded
5151
// TODO: verify if it is even needed by this handler
5252
if (prevValue !== newValue) {
53-
fireInputEvent(element as HTMLInputElement, {
53+
editInputElement(element as HTMLInputElement, {
5454
newValue,
5555
newSelection: {
5656
node: element,
@@ -98,7 +98,7 @@ export const keypressBehavior: behaviorPlugin[] = [
9898
// this check was provided by fireInputEventIfNeeded
9999
// TODO: verify if it is even needed by this handler
100100
if (prevValue !== newValue) {
101-
fireInputEvent(element as HTMLInputElement, {
101+
editInputElement(element as HTMLInputElement, {
102102
newValue,
103103
newSelection: {
104104
node: element,
@@ -129,10 +129,11 @@ export const keypressBehavior: behaviorPlugin[] = [
129129
return
130130
}
131131

132-
const {newValue, commit} = prepareInput(
132+
const {getNewValue, commit} = prepareInput(
133133
keyDef.key as string,
134134
element,
135135
) as NonNullable<ReturnType<typeof prepareInput>>
136+
const newValue = (getNewValue as () => string)()
136137

137138
// the browser allows some invalid input but not others
138139
// it allows up to two '-' at any place before any 'e' or one directly following 'e'

src/utils/edit/calculateNewValue.ts

+2-13
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,9 @@ import {isValidInputTimeValue} from './isValidInputTimeValue'
66
/**
77
* Calculate a new text value.
88
*/
9-
// This implementation does not properly calculate a new DOM state.
10-
// It only handles text values and neither cares for DOM offsets nor accounts for non-character elements.
11-
// It can be used for text nodes and elements supporting value property.
12-
// TODO: The implementation of `deleteContent` is brittle and should be replaced.
139
export function calculateNewValue(
1410
inputData: string,
15-
node:
16-
| (HTMLInputElement & {type: EditableInputType})
17-
| HTMLTextAreaElement
18-
| (Node & {nodeType: 3})
19-
| Text,
11+
node: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement,
2012
{
2113
startOffset,
2214
endOffset,
@@ -26,10 +18,7 @@ export function calculateNewValue(
2618
},
2719
inputType?: string,
2820
) {
29-
const value =
30-
node.nodeType === 3
31-
? String(node.nodeValue)
32-
: getUIValue(node as HTMLInputElement)
21+
const value = getUIValue(node)
3322

3423
const prologEnd = Math.max(
3524
0,

src/utils/edit/fireInputEvent.ts src/utils/edit/editInputElement.ts

+9-18
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import {fireEvent} from '@testing-library/dom'
22
import {setUIValue, startTrackValue, endTrackValue} from '../../document'
3-
import {isElementType} from '../misc/isElementType'
43
import {setSelection} from '../focus/selection'
54

6-
export function fireInputEvent(
7-
element: HTMLElement,
5+
/**
6+
* Change the value of an element as if it was changed as a result of a user input.
7+
*
8+
* Fires the input event.
9+
*/
10+
export function editInputElement(
11+
element: HTMLInputElement | HTMLTextAreaElement,
812
{
913
newValue,
1014
newSelection,
@@ -20,23 +24,10 @@ export function fireInputEvent(
2024
}
2125
},
2226
) {
23-
const oldValue = (element as HTMLInputElement).value
27+
const oldValue = element.value
2428

2529
// apply the changes before firing the input event, so that input handlers can access the altered dom and selection
26-
if (isElementType(element, ['input', 'textarea'])) {
27-
setUIValue(element, newValue)
28-
} else {
29-
// The pre-commit hooks keeps changing this
30-
// See https://github.com/kentcdodds/kcd-scripts/issues/218
31-
/* istanbul ignore else */
32-
// eslint-disable-next-line no-lonely-if
33-
if (newSelection.node.nodeType === 3) {
34-
newSelection.node.textContent = newValue
35-
} else {
36-
// TODO: properly type guard
37-
throw new Error('Invalid Element')
38-
}
39-
}
30+
setUIValue(element, newValue)
4031
setSelection({
4132
focusNode: newSelection.node,
4233
anchorOffset: newSelection.offset,

src/utils/edit/prepareInput.ts

+62-66
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,81 @@
1-
import {UISelectionRange} from '../../document'
2-
import {
3-
calculateNewValue,
4-
EditableInputType,
5-
fireInputEvent,
6-
getInputRange,
7-
} from '../../utils'
1+
import {fireEvent} from '@testing-library/dom'
2+
import {calculateNewValue, editInputElement, getInputRange} from '../../utils'
83

94
export function prepareInput(
105
data: string,
116
element: Element,
127
inputType: string = 'insertText',
13-
):
14-
| {
15-
newValue: string
16-
commit: () => void
17-
}
18-
| undefined {
8+
) {
199
const inputRange = getInputRange(element)
2010

21-
// TODO: implement for ranges on multiple nodes
2211
/* istanbul ignore if */
23-
if (
24-
!inputRange ||
25-
('startContainer' in inputRange &&
26-
inputRange.startContainer !== inputRange.endContainer)
27-
) {
12+
if (!inputRange) {
2813
return
2914
}
30-
const node = getNode(element, inputRange)
3115

32-
const {newValue, newOffset, oldValue} = calculateNewValue(
33-
data,
34-
node,
35-
inputRange,
36-
inputType,
37-
)
16+
if ('startContainer' in inputRange) {
17+
return {
18+
commit: () => {
19+
const del = !inputRange.collapsed
3820

39-
if (
40-
newValue === oldValue &&
41-
newOffset === inputRange.startOffset &&
42-
newOffset === inputRange.endOffset
43-
) {
44-
return
45-
}
21+
if (del) {
22+
inputRange.deleteContents()
23+
}
24+
if (data) {
25+
if (inputRange.endContainer.nodeType === 3) {
26+
const offset = inputRange.endOffset
27+
;(inputRange.endContainer as Text).insertData(offset, data)
28+
inputRange.setStart(inputRange.endContainer, offset + data.length)
29+
inputRange.setEnd(inputRange.endContainer, offset + data.length)
30+
} else {
31+
const text = element.ownerDocument.createTextNode(data)
32+
inputRange.insertNode(text)
33+
inputRange.setStart(text, data.length)
34+
inputRange.setEnd(text, data.length)
35+
}
36+
}
4637

47-
return {
48-
newValue,
49-
commit: () =>
50-
fireInputEvent(element as HTMLElement, {
51-
newValue,
52-
newSelection: {
53-
node,
54-
offset: newOffset,
55-
},
56-
eventOverrides: {
38+
if (del || data) {
39+
fireEvent.input(element, {inputType})
40+
}
41+
},
42+
}
43+
} else {
44+
return {
45+
getNewValue: () =>
46+
calculateNewValue(
47+
data,
48+
element as HTMLTextAreaElement,
49+
inputRange,
5750
inputType,
58-
},
59-
}),
60-
}
61-
}
51+
).newValue,
52+
commit: () => {
53+
const {newValue, newOffset, oldValue} = calculateNewValue(
54+
data,
55+
element as HTMLTextAreaElement,
56+
inputRange,
57+
inputType,
58+
)
6259

63-
function getNode(element: Element, inputRange: Range | UISelectionRange) {
64-
if ('startContainer' in inputRange) {
65-
if (inputRange.startContainer.nodeType === 3) {
66-
return inputRange.startContainer as Text
67-
}
60+
if (
61+
newValue === oldValue &&
62+
newOffset === inputRange.startOffset &&
63+
newOffset === inputRange.endOffset
64+
) {
65+
return
66+
}
6867

69-
try {
70-
return inputRange.startContainer.insertBefore(
71-
element.ownerDocument.createTextNode(''),
72-
inputRange.startContainer.childNodes.item(inputRange.startOffset),
73-
)
74-
} catch {
75-
/* istanbul ignore next */
76-
throw new Error(
77-
'Invalid operation. Can not insert text at this position. The behavior is not implemented yet.',
78-
)
68+
editInputElement(element as HTMLTextAreaElement, {
69+
newValue,
70+
newSelection: {
71+
node: element,
72+
offset: newOffset,
73+
},
74+
eventOverrides: {
75+
inputType,
76+
},
77+
})
78+
},
7979
}
8080
}
81-
82-
return element as
83-
| HTMLTextAreaElement
84-
| (HTMLInputElement & {type: EditableInputType})
8581
}

src/utils/focus/selectAll.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {getUIValue} from '../../document'
1+
import {getUISelection, getUIValue} from '../../document'
22
import {getContentEditable} from '../edit/isContentEditable'
33
import {editableInputTypes} from '../edit/isEditable'
44
import {isElementType} from '../misc/isElementType'
@@ -26,3 +26,25 @@ export function selectAll(target: Element): void {
2626
focusOffset: focusNode.childNodes.length,
2727
})
2828
}
29+
30+
export function isAllSelected(target: Element): boolean {
31+
if (
32+
isElementType(target, 'textarea') ||
33+
(isElementType(target, 'input') && target.type in editableInputTypes)
34+
) {
35+
return (
36+
getUISelection(target).startOffset === 0 &&
37+
getUISelection(target).endOffset === getUIValue(target).length
38+
)
39+
}
40+
41+
const focusNode = getContentEditable(target) ?? target.ownerDocument.body
42+
const selection = target.ownerDocument.getSelection()
43+
44+
return (
45+
selection?.anchorNode === focusNode &&
46+
selection.focusNode === focusNode &&
47+
selection.anchorOffset === 0 &&
48+
selection.focusOffset === focusNode.childNodes.length
49+
)
50+
}

src/utils/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export * from './click/isClickableInput'
22

33
export * from './edit/buildTimeValue'
44
export * from './edit/calculateNewValue'
5-
export * from './edit/fireInputEvent'
5+
export * from './edit/editInputElement'
66
export * from './edit/getValue'
77
export * from './edit/isContentEditable'
88
export * from './edit/isEditable'

0 commit comments

Comments
 (0)