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

feat!: rewrite userEvent.clear API #779

Merged
merged 6 commits into from
Nov 15, 2021
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading