From d7bb6395bf516428c417e67b90a163f62f718b84 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sat, 13 Nov 2021 17:13:31 +0000 Subject: [PATCH 1/5] feat!: rewrite `userEvent.clear` API 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. --- src/clear.ts | 45 +++++------ src/keyboard/plugins/character.ts | 3 +- src/utils/edit/prepareInput.ts | 128 +++++++++++++++--------------- src/utils/focus/selectAll.ts | 24 +++++- tests/clear.js | 98 ----------------------- tests/clear.ts | 109 +++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 191 deletions(-) delete mode 100644 tests/clear.js create mode 100644 tests/clear.ts diff --git a/src/clear.ts b/src/clear.ts index f0ab6900..4809b32a 100644 --- a/src/clear.ts +++ b/src/clear.ts @@ -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() } diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index d4d1317a..7725267f 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -129,10 +129,11 @@ export const keypressBehavior: behaviorPlugin[] = [ return } - const {newValue, commit} = prepareInput( + const {getNewValue, commit} = prepareInput( keyDef.key as string, element, ) as NonNullable> + 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' diff --git a/src/utils/edit/prepareInput.ts b/src/utils/edit/prepareInput.ts index 7230e6a5..fc121781 100644 --- a/src/utils/edit/prepareInput.ts +++ b/src/utils/edit/prepareInput.ts @@ -1,85 +1,81 @@ -import {UISelectionRange} from '../../document' -import { - calculateNewValue, - EditableInputType, - fireInputEvent, - getInputRange, -} from '../../utils' +import {fireEvent} from '@testing-library/dom' +import {calculateNewValue, fireInputEvent, 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.', - ) + fireInputEvent(element as HTMLElement, { + newValue, + newSelection: { + node: element, + offset: newOffset, + }, + eventOverrides: { + inputType, + }, + }) + }, } } - - return element as - | HTMLTextAreaElement - | (HTMLInputElement & {type: EditableInputType}) } diff --git a/src/utils/focus/selectAll.ts b/src/utils/focus/selectAll.ts index ab8b7b45..e2b46ec9 100644 --- a/src/utils/focus/selectAll.ts +++ b/src/utils/focus/selectAll.ts @@ -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' @@ -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 + ) +} diff --git a/tests/clear.js b/tests/clear.js deleted file mode 100644 index 79d39c20..00000000 --- a/tests/clear.js +++ /dev/null @@ -1,98 +0,0 @@ -import userEvent from '#src' -import {setup} from '#testHelpers/utils' - -test('clears text', () => { - const {element, getEventSnapshot} = setup('') - userEvent.clear(element) - expect(element).toHaveValue('') - expect(getEventSnapshot()).toMatchInlineSnapshot(` - Events fired on: input[value=""] - - input[value="hello"] - pointerover - input[value="hello"] - pointerenter - input[value="hello"] - mouseover - input[value="hello"] - mouseenter - input[value="hello"] - pointermove - input[value="hello"] - mousemove - input[value="hello"] - pointerdown - input[value="hello"] - mousedown - input[value="hello"] - focus - input[value="hello"] - focusin - input[value="hello"] - select - input[value="hello"] - pointerup - input[value="hello"] - mouseup - input[value="hello"] - click - input[value="hello"] - select - input[value="hello"] - select - input[value="hello"] - keydown: Delete (46) - input[value=""] - input - input[value=""] - keyup: Delete (46) - `) -}) - -test('works with textarea', () => { - const {element} = setup('') - userEvent.clear(element) - expect(element).toHaveValue('') -}) - -test('does not clear text on disabled inputs', () => { - const {element, getEventSnapshot} = setup('') - userEvent.clear(element) - expect(element).toHaveValue('hello') - expect(getEventSnapshot()).toMatchInlineSnapshot( - `No events were fired on: input[value="hello"]`, - ) -}) - -test('does not clear text on readonly inputs', () => { - const {element, getEventSnapshot} = setup('') - userEvent.clear(element) - expect(element).toHaveValue('hello') - expect(getEventSnapshot()).toMatchInlineSnapshot(` - Events fired on: input[value="hello"] - - input[value="hello"] - pointerover - input[value="hello"] - pointerenter - input[value="hello"] - mouseover - input[value="hello"] - mouseenter - input[value="hello"] - pointermove - input[value="hello"] - mousemove - input[value="hello"] - pointerdown - input[value="hello"] - mousedown - input[value="hello"] - focus - input[value="hello"] - focusin - input[value="hello"] - select - input[value="hello"] - pointerup - input[value="hello"] - mouseup - input[value="hello"] - click - input[value="hello"] - select - input[value="hello"] - select - input[value="hello"] - keydown: Delete (46) - input[value="hello"] - keyup: Delete (46) - `) -}) - -test('clears even on inputs that cannot (programmatically) have a selection', () => { - const {element: email} = setup('') - userEvent.clear(email) - expect(email).toHaveValue('') - - const {element: password} = setup('') - userEvent.clear(password) - expect(password).toHaveValue('') - - const {element: number} = setup('') - userEvent.clear(number) - // jest-dom does funny stuff with toHaveValue on number inputs - // eslint-disable-next-line jest-dom/prefer-to-have-value - expect(number.value).toBe('') -}) - -test('non-inputs/textareas are currently unsupported', () => { - const {element} = setup('
') - - expect(() => userEvent.clear(element)).toThrowErrorMatchingInlineSnapshot( - `clear currently only supports input and textarea elements.`, - ) -}) diff --git a/tests/clear.ts b/tests/clear.ts new file mode 100644 index 00000000..c3f5fc0e --- /dev/null +++ b/tests/clear.ts @@ -0,0 +1,109 @@ +import userEvent from '#src' +import {setup} from '#testHelpers/utils' + +describe('clear elements', () => { + test('clear text input', () => { + const {element, getEventSnapshot} = setup('') + userEvent.clear(element) + expect(element).toHaveValue('') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value=""] + + input[value="hello"] - focus + input[value="hello"] - focusin + input[value="hello"] - select + input[value=""] - input + `) + }) + + test('clear textarea', () => { + const {element, getEventSnapshot} = setup('') + userEvent.clear(element) + expect(element).toHaveValue('') + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: textarea[value=""] + + textarea[value="hello"] - focus + textarea[value="hello"] - focusin + textarea[value="hello"] - select + textarea[value=""] - input + `) + }) + + test('clear contenteditable', () => { + const {element, getEventSnapshot} = setup( + '
hello
', + ) + userEvent.clear(element) + expect(element).toBeEmptyDOMElement() + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: div + + div - focus + div - focusin + div - input + `) + }) + + test('clear inputs that cannot (programmatically) have a selection', () => { + const { + elements: [email, password, number], + } = setup(` + + + + `) + userEvent.clear(email) + expect(email).toHaveValue('') + + userEvent.clear(password) + expect(password).toHaveValue('') + + userEvent.clear(number) + expect(number).toHaveValue(null) + }) +}) + +describe('throw error when clear is impossible', () => { + test('only editable elements can be cleared', () => { + const { + elements: [disabled, readonly, div], + } = setup(` + + +
hello
+ `) + + expect(() => userEvent.clear(disabled)).toThrowErrorMatchingInlineSnapshot( + `clear()\` is only supported on editable elements.`, + ) + expect(() => userEvent.clear(readonly)).toThrowErrorMatchingInlineSnapshot( + `clear()\` is only supported on editable elements.`, + ) + expect(() => userEvent.clear(div)).toThrowErrorMatchingInlineSnapshot( + `clear()\` is only supported on editable elements.`, + ) + }) + + test('abort if event handler prevents element being focused', () => { + const {element} = setup(``) + element.addEventListener('focus', () => element.blur()) + + expect(() => userEvent.clear(element)).toThrowErrorMatchingInlineSnapshot( + `The element to be cleared could not be focused.`, + ) + }) + + test('abort if event handler prevents content being selected', () => { + const {element} = setup(``) + element.addEventListener('select', () => { + if (element.selectionStart === 0) { + element.selectionStart = 1 + } + }) + + expect(() => userEvent.clear(element)).toThrowErrorMatchingInlineSnapshot( + `The element content to be cleared could not be selected.`, + ) + }) +}) From b7e7fe463f5541002a39481dfe25ee24096b84e3 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sat, 13 Nov 2021 18:53:34 +0000 Subject: [PATCH 2/5] remove Text support from `calculateNewValue` --- src/utils/edit/calculateNewValue.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/utils/edit/calculateNewValue.ts b/src/utils/edit/calculateNewValue.ts index a2cb0724..176a6f60 100644 --- a/src/utils/edit/calculateNewValue.ts +++ b/src/utils/edit/calculateNewValue.ts @@ -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, @@ -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, From 0f84c9981172b5d95ad9eef6bff74cb915b3561a Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sat, 13 Nov 2021 19:04:15 +0000 Subject: [PATCH 3/5] narrow scope of `fireInputEvent`->`editInputElement` --- src/keyboard/plugins/character.ts | 6 ++--- ...{fireInputEvent.ts => editInputElement.ts} | 27 +++++++------------ src/utils/edit/prepareInput.ts | 4 +-- src/utils/index.ts | 2 +- 4 files changed, 15 insertions(+), 24 deletions(-) rename src/utils/edit/{fireInputEvent.ts => editInputElement.ts} (71%) diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index 7725267f..fbc67eff 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -8,7 +8,7 @@ import {behaviorPlugin} from '../types' import { buildTimeValue, calculateNewValue, - fireInputEvent, + editInputElement, getInputRange, getSpaceUntilMaxLength, getValue, @@ -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, @@ -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, diff --git a/src/utils/edit/fireInputEvent.ts b/src/utils/edit/editInputElement.ts similarity index 71% rename from src/utils/edit/fireInputEvent.ts rename to src/utils/edit/editInputElement.ts index 9177c1c5..cfa894e8 100644 --- a/src/utils/edit/fireInputEvent.ts +++ b/src/utils/edit/editInputElement.ts @@ -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, @@ -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, diff --git a/src/utils/edit/prepareInput.ts b/src/utils/edit/prepareInput.ts index fc121781..1b8a7d67 100644 --- a/src/utils/edit/prepareInput.ts +++ b/src/utils/edit/prepareInput.ts @@ -1,5 +1,5 @@ import {fireEvent} from '@testing-library/dom' -import {calculateNewValue, fireInputEvent, getInputRange} from '../../utils' +import {calculateNewValue, editInputElement, getInputRange} from '../../utils' export function prepareInput( data: string, @@ -65,7 +65,7 @@ export function prepareInput( return } - fireInputEvent(element as HTMLElement, { + editInputElement(element as HTMLTextAreaElement, { newValue, newSelection: { node: element, diff --git a/src/utils/index.ts b/src/utils/index.ts index 5a76fce6..6be35a5f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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' From 0bf0492ecd517712d238f383cdb625cfb05727ea Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sat, 13 Nov 2021 19:12:43 +0000 Subject: [PATCH 4/5] test backspace at start of contenteditable --- tests/type/index.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/type/index.js b/tests/type/index.js index 96cd2acb..b71ca7cf 100644 --- a/tests/type/index.js +++ b/tests/type/index.js @@ -734,6 +734,37 @@ test('should type inside a contenteditable div', () => { expect(element).toHaveTextContent('bar') }) +test('key event which does not change the contenteditable does not fire input event', () => { + const {element, getEventSnapshot, getEvents} = setup( + '
foo
', + ) + + userEvent.type(element, '[Home][Backspace]') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: div + + div - pointerover + div - pointerenter + div - mouseover + div - mouseenter + div - pointermove + div - mousemove + div - pointerdown + div - mousedown + div - focus + div - focusin + div - pointerup + div - mouseup + div - click + div - keydown: Home (36) + div - keyup: Home (36) + div - keydown: Backspace (8) + div - keyup: Backspace (8) + `) + expect(getEvents('input')).toHaveLength(0) +}) + test('should not type inside a contenteditable=false div', () => { const {element, getEventSnapshot} = setup('
') From 4a37e6a94c3ea8fba6785ad617e8dc4fdc1a06c6 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sat, 13 Nov 2021 19:16:31 +0000 Subject: [PATCH 5/5] test isAllSelected --- tests/utils/focus/selectAll.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/utils/focus/selectAll.ts b/tests/utils/focus/selectAll.ts index dc4250e8..b5c02d32 100644 --- a/tests/utils/focus/selectAll.ts +++ b/tests/utils/focus/selectAll.ts @@ -1,16 +1,20 @@ import {setup} from '#testHelpers/utils' -import {selectAll} from '#src/utils/focus/selectAll' +import {isAllSelected, selectAll} from '#src/utils/focus/selectAll' import {getUISelection} from '#src/document' test('select all in input', () => { const {element} = setup(``) + expect(isAllSelected(element)).toBe(false) + selectAll(element) expect(getUISelection(element)).toHaveProperty('startOffset', 0) expect(getUISelection(element)).toHaveProperty('endOffset', 11) expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 11) + + expect(isAllSelected(element)).toBe(true) }) test('select all in textarea', () => { @@ -18,12 +22,16 @@ test('select all in textarea', () => { ``, ) + expect(isAllSelected(element)).toBe(false) + selectAll(element) expect(getUISelection(element)).toHaveProperty('startOffset', 0) expect(getUISelection(element)).toHaveProperty('endOffset', 11) expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 11) + + expect(isAllSelected(element)).toBe(true) }) test('select all in contenteditable', () => { @@ -32,6 +40,8 @@ test('select all in contenteditable', () => {
baz
`) + expect(isAllSelected(element)).toBe(false) + selectAll(element) const selection = document.getSelection() @@ -39,6 +49,8 @@ test('select all in contenteditable', () => { expect(selection).toHaveProperty('anchorOffset', 0) expect(selection).toHaveProperty('focusNode', element) expect(selection).toHaveProperty('focusOffset', 2) + + expect(isAllSelected(element)).toBe(true) }) test('select all outside of editable', () => { @@ -47,6 +59,8 @@ test('select all outside of editable', () => {
foo
`) + expect(isAllSelected(element)).toBe(false) + selectAll(element) const selection = document.getSelection() @@ -57,4 +71,6 @@ test('select all outside of editable', () => { 'focusOffset', element.ownerDocument.body.childNodes.length, ) + + expect(isAllSelected(element)).toBe(true) })