diff --git a/src/__tests__/document/index.ts b/src/__tests__/document/index.ts new file mode 100644 index 00000000..b782c39c --- /dev/null +++ b/src/__tests__/document/index.ts @@ -0,0 +1,94 @@ +import {setup} from '../helpers/utils' +import { + prepareDocument, + getUIValue, + setUIValue, + getUISelection, + setUISelection, +} from '../../document' + +function prepare(element: Element) { + prepareDocument(element.ownerDocument) + // safe to call multiple times + prepareDocument(element.ownerDocument) + prepareDocument(element.ownerDocument) +} + +test('keep track of value in UI', () => { + const {element} = setup(``) + // The element has to either receive focus or be already focused when preparing. + element.focus() + + prepare(element) + + setUIValue(element, '2e-') + + expect(element).toHaveValue(null) + expect(getUIValue(element)).toBe('2e-') + + element.value = '3' + + expect(element).toHaveValue(3) + expect(getUIValue(element)).toBe('3') +}) + +test('trigger `change` event if value changed since focus/set', () => { + const {element, getEvents} = setup(``) + + prepare(element) + + element.focus() + // Invalid value is equal to empty + setUIValue(element, '2e-') + element.blur() + + expect(getEvents('change')).toHaveLength(0) + + element.focus() + // Programmatically changing value sets initial value + element.value = '3' + setUIValue(element, '3') + element.blur() + + expect(getEvents('change')).toHaveLength(0) + + element.focus() + element.value = '2' + setUIValue(element, '3') + element.blur() + + expect(getEvents('change')).toHaveLength(1) +}) + +test('maintain selection range like UI', () => { + const {element} = setup(``) + + prepare(element) + + element.setSelectionRange(1, 1) + element.focus() + setUIValue(element, 'adbc') + setUISelection(element, 2, 2) + + expect(getUISelection(element)).toEqual({ + selectionStart: 2, + selectionEnd: 2, + }) + expect(element.selectionStart).toBe(2) +}) + +test('maintain selection range on elements without support for selection range', () => { + const {element} = setup(``) + + prepare(element) + + element.focus() + setUIValue(element, '2e-') + setUISelection(element, 2, 2) + + expect(getUISelection(element)).toEqual({ + selectionStart: 2, + selectionEnd: 2, + }) + expect(element.selectionStart).toBe(null) +}) diff --git a/src/__tests__/utils/edit/selectionRange.ts b/src/__tests__/document/selectionRange.ts similarity index 100% rename from src/__tests__/utils/edit/selectionRange.ts rename to src/__tests__/document/selectionRange.ts diff --git a/src/__tests__/keyboard/plugin/character.ts b/src/__tests__/keyboard/plugin/character.ts index c8b6b1f5..2179766a 100644 --- a/src/__tests__/keyboard/plugin/character.ts +++ b/src/__tests__/keyboard/plugin/character.ts @@ -1,3 +1,4 @@ +import {getUIValue} from 'document/value' import userEvent from 'index' import {setup} from '__tests__/helpers/utils' @@ -24,21 +25,23 @@ test('type [Enter] in contenteditable', () => { }) test.each([ - ['1e--5', 1e-5, undefined, 4], + ['1e--5', 1e-5, '1e-5', 4], ['1--e--5', null, '1--e5', 5], ['.-1.-e--5', null, '.-1-e5', 6], - ['1.5e--5', 1.5e-5, undefined, 6], - ['1e5-', 1e5, undefined, 3], + ['1.5e--5', 1.5e-5, '1.5e-5', 6], + ['1e5-', 1e5, '1e5', 3], ])( 'type invalid values into ', - (text, expectedValue, expectedCarryValue, expectedInputEvents) => { - const {element, getEvents} = setup(``) + (text, expectedValue, expectedUiValue, expectedInputEvents) => { + const {element, getEvents} = setup( + ``, + ) element.focus() - const state = userEvent.keyboard(text) + userEvent.keyboard(text) expect(element).toHaveValue(expectedValue) - expect(state).toHaveProperty('carryValue', expectedCarryValue) + expect(getUIValue(element)).toBe(expectedUiValue) expect(getEvents('input')).toHaveLength(expectedInputEvents) }, ) diff --git a/src/__tests__/type.js b/src/__tests__/type.js index d4c43eaa..882ec5df 100644 --- a/src/__tests__/type.js +++ b/src/__tests__/type.js @@ -1503,10 +1503,7 @@ describe('promise rejections', () => { console.error.mockReset() }) - test.each([ - ['foo', '[{', 'Unable to find the "window"'], - [document.body, '[{', 'Expected key descriptor but found "{"'], - ])( + test.each([[document.body, '[{', 'Expected key descriptor but found "{"']])( 'catch promise rejections and report to the console on synchronous calls', async (element, text, errorMessage) => { const errLog = jest diff --git a/src/document/applyNative.ts b/src/document/applyNative.ts new file mode 100644 index 00000000..6ef2b9f1 --- /dev/null +++ b/src/document/applyNative.ts @@ -0,0 +1,28 @@ +/** + * React tracks the changes on element properties. + * This workaround tries to alter the DOM element without React noticing, + * so that it later picks up the change. + * + * @see https://github.com/facebook/react/blob/148f8e497c7d37a3c7ab99f01dec2692427272b1/packages/react-dom/src/client/inputValueTracking.js#L51-L104 + */ +export function applyNative( + element: T, + propName: P, + propValue: T[P], +) { + const descriptor = Object.getOwnPropertyDescriptor(element, propName) + const nativeDescriptor = Object.getOwnPropertyDescriptor( + element.constructor.prototype, + propName, + ) + + if (descriptor && nativeDescriptor) { + Object.defineProperty(element, propName, nativeDescriptor) + } + + element[propName] = propValue + + if (descriptor) { + Object.defineProperty(element, propName, descriptor) + } +} diff --git a/src/document/index.ts b/src/document/index.ts new file mode 100644 index 00000000..d3d2d258 --- /dev/null +++ b/src/document/index.ts @@ -0,0 +1,79 @@ +import {fireEvent} from '@testing-library/dom' +import {prepareSelectionInterceptor} from './selection' +import { + getInitialValue, + prepareValueInterceptor, + setInitialValue, +} from './value' + +const isPrepared = Symbol('Node prepared with document state workarounds') + +declare global { + interface Node { + [isPrepared]?: typeof isPrepared + } +} + +export function prepareDocument(document: Document) { + if (document[isPrepared]) { + return + } + + document.addEventListener( + 'focus', + e => { + const el = e.target as Node + + prepareElement(el) + }, + { + capture: true, + passive: true, + }, + ) + + // Our test environment defaults to `document.body` as `activeElement`. + // In other environments this might be `null` when preparing. + // istanbul ignore else + if (document.activeElement) { + prepareElement(document.activeElement) + } + + document.addEventListener( + 'blur', + e => { + const el = e.target as HTMLInputElement + const initialValue = getInitialValue(el) + if (typeof initialValue === 'string' && el.value !== initialValue) { + fireEvent.change(el) + } + }, + { + capture: true, + passive: true, + }, + ) + + document[isPrepared] = isPrepared +} + +function prepareElement(el: Node | HTMLInputElement) { + if ('value' in el) { + setInitialValue(el) + } + + if (el[isPrepared]) { + return + } + + if ('value' in el) { + prepareValueInterceptor(el) + prepareSelectionInterceptor(el) + } + + el[isPrepared] = isPrepared +} + +export {applyNative} from './applyNative' +export {getUIValue, setUIValue} from './value' +export {getUISelection, hasUISelection, setUISelection} from './selection' diff --git a/src/document/interceptor.ts b/src/document/interceptor.ts new file mode 100644 index 00000000..4c9f3558 --- /dev/null +++ b/src/document/interceptor.ts @@ -0,0 +1,58 @@ +const Interceptor = Symbol('Interceptor for programmatical calls') + +interface Interceptable { + [Interceptor]?: typeof Interceptor +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type anyFunc = (...a: any[]) => any +type Params = Prop extends anyFunc ? Parameters : [Prop] +type ImplReturn = Prop extends anyFunc ? Parameters : Prop + +export function prepareInterceptor< + ElementType extends Element, + PropName extends keyof ElementType, +>( + element: ElementType, + propName: PropName, + interceptorImpl: ( + this: ElementType, + ...args: Params + ) => ImplReturn, +) { + const prototypeDescriptor = Object.getOwnPropertyDescriptor( + element.constructor.prototype, + propName, + ) + + const target = prototypeDescriptor?.set ? 'set' : 'value' + if ( + typeof prototypeDescriptor?.[target] !== 'function' || + (prototypeDescriptor[target] as Interceptable)[Interceptor] + ) { + return + } + + const realFunc = prototypeDescriptor[target] as ( + this: ElementType, + ...args: unknown[] + ) => unknown + function intercept( + this: ElementType, + ...args: Params + ) { + const realArgs = interceptorImpl.call(this, ...args) + + if (target === 'set') { + realFunc.call(this, realArgs) + } else { + realFunc.call(this, ...realArgs) + } + } + ;(intercept as Interceptable)[Interceptor] = Interceptor + + Object.defineProperty(element.constructor.prototype, propName, { + ...prototypeDescriptor, + [target]: intercept, + }) +} diff --git a/src/document/selection.ts b/src/document/selection.ts new file mode 100644 index 00000000..7e104e2d --- /dev/null +++ b/src/document/selection.ts @@ -0,0 +1,86 @@ +import {prepareInterceptor} from './interceptor' + +const UISelection = Symbol('Displayed selection in UI') + +interface Value extends Number { + [UISelection]?: typeof UISelection +} + +declare global { + interface Element { + [UISelection]?: {start: number; end: number} + } +} + +function setSelectionInterceptor( + this: HTMLInputElement | HTMLTextAreaElement, + start: number | Value | null, + end: number | null, + direction: 'forward' | 'backward' | 'none' = 'none', +) { + const isUI = start && typeof start === 'object' && start[UISelection] + + this[UISelection] = isUI + ? {start: start.valueOf(), end: Number(end)} + : undefined + + return [Number(start), end, direction] as Parameters< + HTMLInputElement['setSelectionRange'] + > +} + +export function prepareSelectionInterceptor( + element: HTMLInputElement | HTMLTextAreaElement, +) { + prepareInterceptor(element, 'setSelectionRange', setSelectionInterceptor) +} + +export function setUISelection( + element: HTMLInputElement | HTMLTextAreaElement, + start: number, + end: number, +) { + element[UISelection] = {start, end} + + if (element.selectionStart === start && element.selectionEnd === end) { + return + } + + // eslint-disable-next-line no-new-wrappers + const startObj = new Number(start) + ;(startObj as Value)[UISelection] = UISelection + + try { + element.setSelectionRange(startObj as number, end) + } catch { + // DOMException for invalid state is expected when calling this + // on an element without support for setSelectionRange + } +} + +export function getUISelection( + element: HTMLInputElement | HTMLTextAreaElement, +) { + const ui = element[UISelection] + return ui === undefined + ? { + selectionStart: element.selectionStart, + selectionEnd: element.selectionEnd, + } + : { + selectionStart: ui.start, + selectionEnd: ui.end, + } +} + +export function clearUISelection( + element: HTMLInputElement | HTMLTextAreaElement, +) { + element[UISelection] = undefined +} + +export function hasUISelection( + element: HTMLInputElement | HTMLTextAreaElement, +) { + return Boolean(element[UISelection]) +} diff --git a/src/document/value.ts b/src/document/value.ts new file mode 100644 index 00000000..279db7b2 --- /dev/null +++ b/src/document/value.ts @@ -0,0 +1,66 @@ +import {applyNative} from './applyNative' +import {prepareInterceptor} from './interceptor' +import {clearUISelection} from './selection' + +const UIValue = Symbol('Displayed value in UI') +const InitialValue = Symbol('Initial value to compare on blur') + +type Value = { + [UIValue]?: typeof UIValue + toString(): string +} + +declare global { + interface Element { + [UIValue]?: string + [InitialValue]?: string + } +} + +function valueInterceptor( + this: HTMLInputElement | HTMLTextAreaElement, + v: Value | string, +) { + const isUI = typeof v === 'object' && v[UIValue] + + this[UIValue] = isUI ? String(v) : undefined + if (!isUI) { + this[InitialValue] = String(v) + + // Programmatically setting the value property + // moves the cursor to the end of the input. + clearUISelection(this) + } + + return String(v) +} + +export function prepareValueInterceptor(element: HTMLInputElement) { + prepareInterceptor(element, 'value', valueInterceptor) +} + +export function setUIValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + applyNative(element, 'value', { + [UIValue]: UIValue, + toString: () => value, + } as unknown as string) +} + +export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) { + return element[UIValue] === undefined ? element.value : element[UIValue] +} + +export function setInitialValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + element[InitialValue] = element.value +} + +export function getInitialValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + return element[InitialValue] +} diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 51822658..64becce2 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -1,4 +1,5 @@ import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {prepareDocument} from '../document' import {keyboardImplementation, releaseAllKeys} from './keyboardImplementation' import {defaultKeyMap} from './keyMap' import {keyboardState, keyboardOptions, keyboardKey} from './types' @@ -52,6 +53,8 @@ export function keyboardImplementationWrapper( keyboardMap, } + prepareDocument(document) + return { promise: keyboardImplementation(text, options, state), state, diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index 5a102a21..8aa97ffc 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -3,11 +3,12 @@ */ import {fireEvent} from '@testing-library/dom' -import {fireChangeForInputTimeIfValid, fireInputEvent} from '../shared' +import {fireChangeForInputTimeIfValid} from '../shared' import {behaviorPlugin} from '../types' import { buildTimeValue, calculateNewValue, + fireInputEvent, getSpaceUntilMaxLength, getValue, isClickableInput, @@ -112,18 +113,14 @@ export const keypressBehavior: behaviorPlugin[] = [ matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'number', readOnly: false}), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element) => { if (!/[\d.\-e]/.test(keyDef.key as string)) { return } - const oldValue = - state.carryValue ?? getValue(element) ?? /* istanbul ignore next */ '' - const {newValue, newSelectionStart} = calculateNewValue( keyDef.key as string, element as HTMLElement, - oldValue, ) // the browser allows some invalid input but not others @@ -146,13 +143,6 @@ export const keypressBehavior: behaviorPlugin[] = [ inputType: 'insertText', }, }) - - const appliedValue = getValue(element) - if (appliedValue === newValue) { - state.carryValue = undefined - } else { - state.carryValue = newValue - } }, }, { diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts index bd5d8c72..c38008f5 100644 --- a/src/keyboard/plugins/control.ts +++ b/src/keyboard/plugins/control.ts @@ -6,6 +6,7 @@ import {behaviorPlugin} from '../types' import { calculateNewValue, + fireInputEvent, getValue, isContentEditable, isCursorAtEnd, @@ -13,7 +14,6 @@ import { isElementType, setSelectionRange, } from '../../utils' -import {carryValue, fireInputEvent} from '../shared' export const keydownBehavior: behaviorPlugin[] = [ { @@ -48,11 +48,11 @@ export const keydownBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key === 'Delete' && isEditable(element) && !isCursorAtEnd(element), - handle: (keDef, element, options, state) => { + handle: (keDef, element) => { const {newValue, newSelectionStart} = calculateNewValue( '', element as HTMLElement, - state.carryValue, + undefined, undefined, 'forward', ) @@ -64,8 +64,6 @@ export const keydownBehavior: behaviorPlugin[] = [ inputType: 'deleteContentForward', }, }) - - carryValue(element, state, newValue) }, }, ] diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts index 564b0a70..80c2511e 100644 --- a/src/keyboard/plugins/functional.ts +++ b/src/keyboard/plugins/functional.ts @@ -6,6 +6,7 @@ import {fireEvent} from '@testing-library/dom' import { calculateNewValue, + fireInputEvent, hasFormSubmit, isClickableInput, isCursorAtStart, @@ -13,7 +14,6 @@ import { isElementType, } from '../../utils' import {getKeyEventProps, getMouseEventProps} from '../getEventProps' -import {carryValue, fireInputEvent} from '../shared' import {behaviorPlugin} from '../types' const modifierKeys = { @@ -59,11 +59,11 @@ export const keydownBehavior: behaviorPlugin[] = [ keyDef.key === 'Backspace' && isEditable(element) && !isCursorAtStart(element), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element) => { const {newValue, newSelectionStart} = calculateNewValue( '', element as HTMLElement, - state.carryValue, + undefined, undefined, 'backward', ) @@ -75,8 +75,6 @@ export const keydownBehavior: behaviorPlugin[] = [ inputType: 'deleteContentBackward', }, }) - - carryValue(element, state, newValue) }, }, ] diff --git a/src/keyboard/plugins/index.ts b/src/keyboard/plugins/index.ts index 54c2f283..5e4d041e 100644 --- a/src/keyboard/plugins/index.ts +++ b/src/keyboard/plugins/index.ts @@ -1,5 +1,5 @@ import {behaviorPlugin} from '../types' -import {isElementType, setSelectionRange} from '../../utils' +import {getValue, isElementType, setSelectionRange} from '../../utils' import * as arrowKeys from './arrow' import * as controlKeys from './control' import * as characterKeys from './character' @@ -10,14 +10,11 @@ export const replaceBehavior: behaviorPlugin[] = [ matches: (keyDef, element) => keyDef.key === 'selectall' && isElementType(element, ['input', 'textarea']), - handle: (keyDef, element, options, state) => { + handle: (keyDef, element) => { setSelectionRange( element, 0, - ( - state.carryValue ?? - (element as HTMLInputElement | HTMLTextAreaElement).value - ).length, + getValue(element as HTMLInputElement).length, ) }, }, diff --git a/src/keyboard/shared/carryValue.ts b/src/keyboard/shared/carryValue.ts deleted file mode 100644 index 983c0cd4..00000000 --- a/src/keyboard/shared/carryValue.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {getValue, hasUnreliableEmptyValue} from '../../utils' -import {keyboardState} from '../types' - -export function carryValue( - element: Element, - state: keyboardState, - newValue: string, -) { - const value = getValue(element) - state.carryValue = - value !== newValue && value === '' && hasUnreliableEmptyValue(element) - ? newValue - : undefined -} diff --git a/src/keyboard/shared/fireInputEvent.ts b/src/keyboard/shared/fireInputEvent.ts deleted file mode 100644 index 7541cb72..00000000 --- a/src/keyboard/shared/fireInputEvent.ts +++ /dev/null @@ -1,140 +0,0 @@ -import {fireEvent} from '@testing-library/dom' -import { - isElementType, - getValue, - hasUnreliableEmptyValue, - isContentEditable, - setSelectionRange, - getSelectionRange, -} from '../../utils' - -export function fireInputEvent( - element: HTMLElement, - { - newValue, - newSelectionStart, - eventOverrides, - }: { - newValue: string - newSelectionStart: number - eventOverrides: Partial[1]> & { - [k: string]: unknown - } - }, -) { - // apply the changes before firing the input event, so that input handlers can access the altered dom and selection - if (isContentEditable(element)) { - applyNative(element, 'textContent', newValue) - } else /* istanbul ignore else */ if ( - isElementType(element, ['input', 'textarea']) - ) { - applyNative(element, 'value', newValue) - } else { - // TODO: properly type guard - throw new Error('Invalid Element') - } - setSelectionRangeAfterInput(element, newSelectionStart) - - fireEvent.input(element, { - ...eventOverrides, - }) - - setSelectionRangeAfterInputHandler(element, newValue, newSelectionStart) -} - -function setSelectionRangeAfterInput( - element: Element, - newSelectionStart: number, -) { - setSelectionRange(element, newSelectionStart, newSelectionStart) -} - -function setSelectionRangeAfterInputHandler( - element: Element, - newValue: string, - newSelectionStart: number, -) { - const value = getValue(element) as string - - // don't apply this workaround on elements that don't necessarily report the visible value - e.g. number - // TODO: this could probably be only applied when there is keyboardState.carryValue - const isUnreliableValue = value === '' && hasUnreliableEmptyValue(element) - - if (!isUnreliableValue && value === newValue) { - const {selectionStart} = getSelectionRange(element) - if (selectionStart === value.length) { - // The value was changed as expected, but the cursor was moved to the end - // TODO: this could probably be only applied when we work around a framework setter on the element in applyNative - setSelectionRange(element, newSelectionStart, newSelectionStart) - } - } -} - -const initial = Symbol('initial input value/textContent') -const onBlur = Symbol('onBlur') -declare global { - interface Element { - [initial]?: string - [onBlur]?: EventListener - } -} - -/** - * React tracks the changes on element properties. - * This workaround tries to alter the DOM element without React noticing, - * so that it later picks up the change. - * - * @see https://github.com/facebook/react/blob/148f8e497c7d37a3c7ab99f01dec2692427272b1/packages/react-dom/src/client/inputValueTracking.js#L51-L104 - */ -function applyNative( - element: T, - propName: P, - propValue: T[P], -) { - const descriptor = Object.getOwnPropertyDescriptor(element, propName) - const nativeDescriptor = Object.getOwnPropertyDescriptor( - element.constructor.prototype, - propName, - ) - - if (descriptor && nativeDescriptor) { - Object.defineProperty(element, propName, nativeDescriptor) - } - - // Keep track of the initial value to determine if a change event should be dispatched. - // CONSTRAINT: We can not determine what happened between focus event and our first API call. - if (element[initial] === undefined) { - element[initial] = String(element[propName]) - } - - element[propName] = propValue - - // Add an event listener for the blur event to the capture phase on the window. - // CONSTRAINT: Currently there is no cross-platform solution to unshift the event handler stack. - // Our change event might occur after other event handlers on the blur event have been processed. - if (!element[onBlur]) { - element.ownerDocument.defaultView?.addEventListener( - 'blur', - (element[onBlur] = () => { - const initV = element[initial] - - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete element[onBlur] - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete element[initial] - - if (String(element[propName]) !== initV) { - fireEvent.change(element) - } - }), - { - capture: true, - once: true, - }, - ) - } - - if (descriptor) { - Object.defineProperty(element, propName, descriptor) - } -} diff --git a/src/keyboard/shared/index.ts b/src/keyboard/shared/index.ts index 30de28ac..3773daa9 100644 --- a/src/keyboard/shared/index.ts +++ b/src/keyboard/shared/index.ts @@ -1,3 +1 @@ -export * from './carryValue' export * from './fireChangeForInputTimeIfValid' -export * from './fireInputEvent' diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts index 70cead57..1d66efcb 100644 --- a/src/keyboard/types.ts +++ b/src/keyboard/types.ts @@ -30,6 +30,9 @@ export type keyboardState = { For HTMLInputElements type='number': If the last input char is '.', '-' or 'e', the IDL value attribute does not reflect the input value. + + @deprecated The document state workaround in `src/document/value.ts` keeps track + of UI value diverging from value property. */ carryValue?: string diff --git a/src/type/index.ts b/src/type/index.ts index 74c99edd..1bc1377f 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -1,4 +1,5 @@ import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {prepareDocument} from 'document' import {typeImplementation, typeOptions} from './typeImplementation' export function type( @@ -18,6 +19,8 @@ export function type( text: string, {delay = 0, ...options}: typeOptions = {}, ): Promise | void { + prepareDocument(element.ownerDocument) + // we do not want to wrap in the asyncWrapper if we're not // going to actually be doing anything async, so we only wrap // if the delay is greater than 0 diff --git a/src/utils/edit/fireInputEvent.ts b/src/utils/edit/fireInputEvent.ts new file mode 100644 index 00000000..7e7bcce0 --- /dev/null +++ b/src/utils/edit/fireInputEvent.ts @@ -0,0 +1,65 @@ +import {fireEvent} from '@testing-library/dom' +import {isElementType} from '../misc/isElementType' +import {applyNative, hasUISelection, setUIValue} from '../../document' +import {isContentEditable} from './isContentEditable' +import {setSelectionRange} from './selectionRange' + +export function fireInputEvent( + element: HTMLElement, + { + newValue, + newSelectionStart, + eventOverrides, + }: { + newValue: string + newSelectionStart: number + eventOverrides: Partial[1]> & { + [k: string]: unknown + } + }, +) { + // apply the changes before firing the input event, so that input handlers can access the altered dom and selection + if (isContentEditable(element)) { + applyNative(element, 'textContent', newValue) + } else /* istanbul ignore else */ if ( + isElementType(element, ['input', 'textarea']) + ) { + setUIValue(element, newValue) + } else { + // TODO: properly type guard + throw new Error('Invalid Element') + } + setSelectionRangeAfterInput(element, newSelectionStart) + + fireEvent.input(element, { + ...eventOverrides, + }) + + setSelectionRangeAfterInputHandler(element, newSelectionStart) +} + +function setSelectionRangeAfterInput( + element: Element, + newSelectionStart: number, +) { + setSelectionRange(element, newSelectionStart, newSelectionStart) +} + +function setSelectionRangeAfterInputHandler( + element: Element, + newSelectionStart: number, +) { + // On controlled inputs the selection changes without a call to + // either the `value` setter or the `setSelectionRange` method. + // So if our tracked position for UI still exists and derives from a valid selectionStart, + // the cursor was moved due to an input being controlled. + + if ( + isElementType(element, ['input', 'textarea']) && + typeof element.selectionStart === 'number' && + element.selectionStart !== newSelectionStart && + hasUISelection(element) + ) { + setSelectionRange(element, newSelectionStart, newSelectionStart) + } +} diff --git a/src/utils/edit/getValue.ts b/src/utils/edit/getValue.ts index 126e258f..1f010332 100644 --- a/src/utils/edit/getValue.ts +++ b/src/utils/edit/getValue.ts @@ -1,5 +1,9 @@ +import {getUIValue} from '../../document' import {isContentEditable} from './isContentEditable' +export function getValue( + element: T, +): T extends HTMLInputElement | HTMLTextAreaElement ? string : string | null export function getValue(element: Element | null): string | null { // istanbul ignore if if (!element) { @@ -8,5 +12,5 @@ export function getValue(element: Element | null): string | null { if (isContentEditable(element)) { return element.textContent } - return (element as HTMLInputElement).value + return getUIValue(element as HTMLInputElement) ?? null } diff --git a/src/utils/edit/hasUnreliableEmptyValue.ts b/src/utils/edit/hasUnreliableEmptyValue.ts deleted file mode 100644 index 7eb505d9..00000000 --- a/src/utils/edit/hasUnreliableEmptyValue.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {isElementType} from '../misc/isElementType' - -enum unreliableValueInputTypes { - 'number' = 'number', -} - -/** - * Check if an empty IDL value on the element could mean a derivation of displayed value and IDL value - */ -export function hasUnreliableEmptyValue( - element: Element, -): element is HTMLInputElement & {type: unreliableValueInputTypes} { - return ( - isElementType(element, 'input') && - Boolean( - unreliableValueInputTypes[ - element.type as keyof typeof unreliableValueInputTypes - ], - ) - ) -} diff --git a/src/utils/edit/selectionRange.ts b/src/utils/edit/selectionRange.ts index a40bda48..57c83df4 100644 --- a/src/utils/edit/selectionRange.ts +++ b/src/utils/edit/selectionRange.ts @@ -1,56 +1,12 @@ import {isElementType} from '../misc/isElementType' +import {getUISelection, setUISelection} from '../../document' -// https://github.com/jsdom/jsdom/blob/c2fb8ff94917a4d45e2398543f5dd2a8fed0bdab/lib/jsdom/living/nodes/HTMLInputElement-impl.js#L45 -enum selectionSupportType { - 'text' = 'text', - 'search' = 'search', - 'url' = 'url', - 'tel' = 'tel', - 'password' = 'password', -} - -const InputSelection = Symbol('inputSelection') -type InputWithInternalSelection = HTMLInputElement & { - [InputSelection]?: { - selectionStart: number - selectionEnd: number - } -} - -export function hasSelectionSupport( - element: Element, -): element is - | HTMLTextAreaElement - | (HTMLInputElement & {type: selectionSupportType}) { - return ( - isElementType(element, 'textarea') || - (isElementType(element, 'input') && - Boolean( - selectionSupportType[element.type as keyof typeof selectionSupportType], - )) - ) -} - -export function getSelectionRange( - element: Element, -): { +export function getSelectionRange(element: Element): { selectionStart: number | null selectionEnd: number | null } { - if (hasSelectionSupport(element)) { - return { - selectionStart: element.selectionStart, - selectionEnd: element.selectionEnd, - } - } - - if (isElementType(element, 'input')) { - return ( - (element as InputWithInternalSelection)[InputSelection] ?? { - selectionStart: null, - selectionEnd: null, - } - ) + if (isElementType(element, ['input', 'textarea'])) { + return getUISelection(element) } const selection = element.ownerDocument.getSelection() @@ -76,8 +32,14 @@ export function setSelectionRange( newSelectionStart: number, newSelectionEnd: number, ) { + if (isElementType(element, ['input', 'textarea'])) { + return setUISelection(element, newSelectionStart, newSelectionEnd) + } + const {selectionStart, selectionEnd} = getSelectionRange(element) + // Prevent unnecessary select events + // istanbul ignore next if ( selectionStart === newSelectionStart && selectionEnd === newSelectionEnd @@ -85,22 +47,6 @@ export function setSelectionRange( return } - if (hasSelectionSupport(element)) { - element.setSelectionRange(newSelectionStart, newSelectionEnd) - } - - if (isElementType(element, 'input')) { - ;(element as InputWithInternalSelection)[InputSelection] = { - selectionStart: newSelectionStart, - selectionEnd: newSelectionEnd, - } - } - - // Moving the selection inside or