Skip to content

Commit

Permalink
feat!: rewrite userEvent.clear API (#779)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ph-fritsche committed Nov 28, 2021
1 parent da5b5b7 commit 1cda1b1
Show file tree
Hide file tree
Showing 11 changed files with 279 additions and 227 deletions.
45 changes: 20 additions & 25 deletions src/clear.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,32 @@
import {isDisabled, isElementType} from './utils'
import {prepareDocument} from './document'
import type {UserEvent} from './setup'
import {
focus,
isAllSelected,
isDisabled,
isEditable,
prepareInput,
selectAll,
} from './utils'

export function clear(this: UserEvent, element: Element) {
if (!isElementType(element, ['input', 'textarea'])) {
// TODO: support contenteditable
throw new Error(
'clear currently only supports input and textarea elements.',
)
if (!isEditable(element) || isDisabled(element)) {
throw new Error('clear()` is only supported on editable elements.')
}

if (isDisabled(element)) {
return
}

// TODO: track the selection range ourselves so we don't have to do this input "type" trickery
// just like cypress does: https://github.com/cypress-io/cypress/blob/8d7f1a0bedc3c45a2ebf1ff50324b34129fdc683/packages/driver/src/dom/selection.ts#L16-L37
prepareDocument(element.ownerDocument)

const elementType = element.type
focus(element)

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

this.type(element, '{selectall}{del}', {
delay: 0,
initialSelectionStart:
element.selectionStart ?? /* istanbul ignore next */ undefined,
initialSelectionEnd:
element.selectionEnd ?? /* istanbul ignore next */ undefined,
})
selectAll(element)

if (elementType !== 'textarea') {
;(element as HTMLInputElement).type = elementType
if (!isAllSelected(element)) {
throw new Error('The element content to be cleared could not be selected.')
}

prepareInput('', element, 'deleteContentBackward')?.commit()
}
9 changes: 5 additions & 4 deletions src/keyboard/plugins/character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {behaviorPlugin} from '../types'
import {
buildTimeValue,
calculateNewValue,
fireInputEvent,
editInputElement,
getInputRange,
getSpaceUntilMaxLength,
getValue,
Expand Down Expand Up @@ -50,7 +50,7 @@ export const keypressBehavior: behaviorPlugin[] = [
// this check was provided by fireInputEventIfNeeded
// TODO: verify if it is even needed by this handler
if (prevValue !== newValue) {
fireInputEvent(element as HTMLInputElement, {
editInputElement(element as HTMLInputElement, {
newValue,
newSelection: {
node: element,
Expand Down Expand Up @@ -98,7 +98,7 @@ export const keypressBehavior: behaviorPlugin[] = [
// this check was provided by fireInputEventIfNeeded
// TODO: verify if it is even needed by this handler
if (prevValue !== newValue) {
fireInputEvent(element as HTMLInputElement, {
editInputElement(element as HTMLInputElement, {
newValue,
newSelection: {
node: element,
Expand Down Expand Up @@ -129,10 +129,11 @@ export const keypressBehavior: behaviorPlugin[] = [
return
}

const {newValue, commit} = prepareInput(
const {getNewValue, commit} = prepareInput(
keyDef.key as string,
element,
) as NonNullable<ReturnType<typeof prepareInput>>
const newValue = (getNewValue as () => string)()

// the browser allows some invalid input but not others
// it allows up to two '-' at any place before any 'e' or one directly following 'e'
Expand Down
15 changes: 2 additions & 13 deletions src/utils/edit/calculateNewValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,9 @@ import {isValidInputTimeValue} from './isValidInputTimeValue'
/**
* Calculate a new text value.
*/
// This implementation does not properly calculate a new DOM state.
// It only handles text values and neither cares for DOM offsets nor accounts for non-character elements.
// It can be used for text nodes and elements supporting value property.
// TODO: The implementation of `deleteContent` is brittle and should be replaced.
export function calculateNewValue(
inputData: string,
node:
| (HTMLInputElement & {type: EditableInputType})
| HTMLTextAreaElement
| (Node & {nodeType: 3})
| Text,
node: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement,
{
startOffset,
endOffset,
Expand All @@ -26,10 +18,7 @@ export function calculateNewValue(
},
inputType?: string,
) {
const value =
node.nodeType === 3
? String(node.nodeValue)
: getUIValue(node as HTMLInputElement)
const value = getUIValue(node)

const prologEnd = Math.max(
0,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {fireEvent} from '@testing-library/dom'
import {setUIValue, startTrackValue, endTrackValue} from '../../document'
import {isElementType} from '../misc/isElementType'
import {setSelection} from '../focus/selection'

export function fireInputEvent(
element: HTMLElement,
/**
* Change the value of an element as if it was changed as a result of a user input.
*
* Fires the input event.
*/
export function editInputElement(
element: HTMLInputElement | HTMLTextAreaElement,
{
newValue,
newSelection,
Expand All @@ -20,23 +24,10 @@ export function fireInputEvent(
}
},
) {
const oldValue = (element as HTMLInputElement).value
const oldValue = element.value

// apply the changes before firing the input event, so that input handlers can access the altered dom and selection
if (isElementType(element, ['input', 'textarea'])) {
setUIValue(element, newValue)
} else {
// The pre-commit hooks keeps changing this
// See https://github.com/kentcdodds/kcd-scripts/issues/218
/* istanbul ignore else */
// eslint-disable-next-line no-lonely-if
if (newSelection.node.nodeType === 3) {
newSelection.node.textContent = newValue
} else {
// TODO: properly type guard
throw new Error('Invalid Element')
}
}
setUIValue(element, newValue)
setSelection({
focusNode: newSelection.node,
anchorOffset: newSelection.offset,
Expand Down
128 changes: 62 additions & 66 deletions src/utils/edit/prepareInput.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,81 @@
import {UISelectionRange} from '../../document'
import {
calculateNewValue,
EditableInputType,
fireInputEvent,
getInputRange,
} from '../../utils'
import {fireEvent} from '@testing-library/dom'
import {calculateNewValue, editInputElement, getInputRange} from '../../utils'

export function prepareInput(
data: string,
element: Element,
inputType: string = 'insertText',
):
| {
newValue: string
commit: () => void
}
| undefined {
) {
const inputRange = getInputRange(element)

// TODO: implement for ranges on multiple nodes
/* istanbul ignore if */
if (
!inputRange ||
('startContainer' in inputRange &&
inputRange.startContainer !== inputRange.endContainer)
) {
if (!inputRange) {
return
}
const node = getNode(element, inputRange)

const {newValue, newOffset, oldValue} = calculateNewValue(
data,
node,
inputRange,
inputType,
)
if ('startContainer' in inputRange) {
return {
commit: () => {
const del = !inputRange.collapsed

if (
newValue === oldValue &&
newOffset === inputRange.startOffset &&
newOffset === inputRange.endOffset
) {
return
}
if (del) {
inputRange.deleteContents()
}
if (data) {
if (inputRange.endContainer.nodeType === 3) {
const offset = inputRange.endOffset
;(inputRange.endContainer as Text).insertData(offset, data)
inputRange.setStart(inputRange.endContainer, offset + data.length)
inputRange.setEnd(inputRange.endContainer, offset + data.length)
} else {
const text = element.ownerDocument.createTextNode(data)
inputRange.insertNode(text)
inputRange.setStart(text, data.length)
inputRange.setEnd(text, data.length)
}
}

return {
newValue,
commit: () =>
fireInputEvent(element as HTMLElement, {
newValue,
newSelection: {
node,
offset: newOffset,
},
eventOverrides: {
if (del || data) {
fireEvent.input(element, {inputType})
}
},
}
} else {
return {
getNewValue: () =>
calculateNewValue(
data,
element as HTMLTextAreaElement,
inputRange,
inputType,
},
}),
}
}
).newValue,
commit: () => {
const {newValue, newOffset, oldValue} = calculateNewValue(
data,
element as HTMLTextAreaElement,
inputRange,
inputType,
)

function getNode(element: Element, inputRange: Range | UISelectionRange) {
if ('startContainer' in inputRange) {
if (inputRange.startContainer.nodeType === 3) {
return inputRange.startContainer as Text
}
if (
newValue === oldValue &&
newOffset === inputRange.startOffset &&
newOffset === inputRange.endOffset
) {
return
}

try {
return inputRange.startContainer.insertBefore(
element.ownerDocument.createTextNode(''),
inputRange.startContainer.childNodes.item(inputRange.startOffset),
)
} catch {
/* istanbul ignore next */
throw new Error(
'Invalid operation. Can not insert text at this position. The behavior is not implemented yet.',
)
editInputElement(element as HTMLTextAreaElement, {
newValue,
newSelection: {
node: element,
offset: newOffset,
},
eventOverrides: {
inputType,
},
})
},
}
}

return element as
| HTMLTextAreaElement
| (HTMLInputElement & {type: EditableInputType})
}
24 changes: 23 additions & 1 deletion src/utils/focus/selectAll.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {getUIValue} from '../../document'
import {getUISelection, getUIValue} from '../../document'
import {getContentEditable} from '../edit/isContentEditable'
import {editableInputTypes} from '../edit/isEditable'
import {isElementType} from '../misc/isElementType'
Expand Down Expand Up @@ -26,3 +26,25 @@ export function selectAll(target: Element): void {
focusOffset: focusNode.childNodes.length,
})
}

export function isAllSelected(target: Element): boolean {
if (
isElementType(target, 'textarea') ||
(isElementType(target, 'input') && target.type in editableInputTypes)
) {
return (
getUISelection(target).startOffset === 0 &&
getUISelection(target).endOffset === getUIValue(target).length
)
}

const focusNode = getContentEditable(target) ?? target.ownerDocument.body
const selection = target.ownerDocument.getSelection()

return (
selection?.anchorNode === focusNode &&
selection.focusNode === focusNode &&
selection.anchorOffset === 0 &&
selection.focusOffset === focusNode.childNodes.length
)
}
2 changes: 1 addition & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export * from './click/isClickableInput'

export * from './edit/buildTimeValue'
export * from './edit/calculateNewValue'
export * from './edit/fireInputEvent'
export * from './edit/editInputElement'
export * from './edit/getValue'
export * from './edit/isContentEditable'
export * from './edit/isEditable'
Expand Down

0 comments on commit 1cda1b1

Please sign in to comment.