diff --git a/.all-contributorsrc b/.all-contributorsrc index a621a1e6..0891ae03 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1198,6 +1198,16 @@ "contributions": [ "bug" ] + }, + { + "login": "karolis-cekaitis", + "name": "Karolis ฤŒekaitis", + "avatar_url": "https://avatars.githubusercontent.com/u/89905443?v=4", + "profile": "https://github.com/karolis-cekaitis", + "contributions": [ + "bug", + "doc" + ] } ], "commitConvention": "none", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2411fb2a..37e650f0 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -164,6 +164,7 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Rob Caldecott

๐Ÿ› ๐Ÿ’ป
Tom Bertrand

๐Ÿ›
Justin Hall

๐Ÿ› +
Karolis ฤŒekaitis

๐Ÿ› ๐Ÿ“– diff --git a/src/document/index.ts b/src/document/index.ts index 7cb2e3d5..70be9b06 100644 --- a/src/document/index.ts +++ b/src/document/index.ts @@ -2,9 +2,9 @@ import {dispatchUIEvent} from '../event' import {Config} from '../setup' import {prepareSelectionInterceptor} from './selection' import { + clearInitialValue, getInitialValue, prepareValueInterceptor, - setInitialValue, } from './value' const isPrepared = Symbol('Node prepared with document state workarounds') @@ -45,8 +45,11 @@ export function prepareDocument(document: Document) { e => { const el = e.target as HTMLInputElement const initialValue = getInitialValue(el) - if (typeof initialValue === 'string' && el.value !== initialValue) { - dispatchUIEvent({} as Config, el, 'change') + if (initialValue !== undefined) { + if (el.value !== initialValue) { + dispatchUIEvent({} as Config, el, 'change') + } + clearInitialValue(el) } }, { @@ -59,10 +62,6 @@ export function prepareDocument(document: Document) { } function prepareElement(el: Node | HTMLInputElement) { - if ('value' in el) { - setInitialValue(el) - } - if (el[isPrepared]) { return } @@ -75,6 +74,12 @@ function prepareElement(el: Node | HTMLInputElement) { el[isPrepared] = isPrepared } -export {getUIValue, setUIValue, startTrackValue, endTrackValue} from './value' +export { + getUIValue, + setUIValue, + startTrackValue, + endTrackValue, + clearInitialValue, +} from './value' export {getUISelection, setUISelection} from './selection' export type {UISelectionRange} from './selection' diff --git a/src/document/interceptor.ts b/src/document/interceptor.ts index 4ff51e11..6d6ecc2f 100644 --- a/src/document/interceptor.ts +++ b/src/document/interceptor.ts @@ -28,6 +28,7 @@ export function prepareInterceptor< */ applyNative?: boolean realArgs?: ImplReturn + then?: () => void }, ) { const prototypeDescriptor = Object.getOwnPropertyDescriptor( @@ -49,7 +50,11 @@ export function prepareInterceptor< this: ElementType, ...args: Params ) { - const {applyNative = true, realArgs} = interceptorImpl.call(this, ...args) + const { + applyNative = true, + realArgs, + then, + } = interceptorImpl.call(this, ...args) const realFunc = ((!applyNative && objectDescriptor) || (prototypeDescriptor as PropertyDescriptor))[target] as ( @@ -62,6 +67,8 @@ export function prepareInterceptor< } else { realFunc.call(this, ...realArgs) } + + then?.() } ;(intercept as Interceptable)[Interceptor] = Interceptor diff --git a/src/document/value.ts b/src/document/value.ts index c91a85c1..0a410034 100644 --- a/src/document/value.ts +++ b/src/document/value.ts @@ -32,13 +32,12 @@ function valueInterceptor( if (isUI) { this[UIValue] = String(v) setPreviousValue(this, String(this.value)) - } else { - trackOrSetValue(this, String(v)) } return { applyNative: !!isUI, realArgs: sanitizeValue(this, v), + then: isUI ? undefined : () => trackOrSetValue(this, String(v)), } } @@ -66,6 +65,10 @@ export function setUIValue( element: HTMLInputElement | HTMLTextAreaElement, value: string, ) { + if (element[InitialValue] === undefined) { + element[InitialValue] = element.value + } + element.value = { [UIValue]: UIValue, toString: () => value, @@ -78,10 +81,10 @@ export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) { : String(element[UIValue]) } -export function setInitialValue( +export function clearInitialValue( element: HTMLInputElement | HTMLTextAreaElement, ) { - element[InitialValue] = element.value + element[InitialValue] = undefined } export function getInitialValue( @@ -123,7 +126,6 @@ function setCleanValue( v: string, ) { element[UIValue] = undefined - element[InitialValue] = v // Programmatically setting the value property // moves the cursor to the end of the input. diff --git a/src/keyboard/keyboardAction.ts b/src/keyboard/keyboardAction.ts index 2a0f444e..d4887978 100644 --- a/src/keyboard/keyboardAction.ts +++ b/src/keyboard/keyboardAction.ts @@ -22,8 +22,8 @@ export async function keyboardAction( for (let i = 0; i < actions.length; i++) { await keyboardKeyAction(config, actions[i]) - if (typeof config.delay === 'number' && i < actions.length - 1) { - await wait(config.delay) + if (i < actions.length - 1) { + await wait(config) } } } @@ -32,7 +32,7 @@ async function keyboardKeyAction( config: Config, {keyDef, releasePrevious, releaseSelf, repeat}: KeyboardAction, ) { - const {document, keyboardState, delay} = config + const {document, keyboardState} = config const getCurrentElement = () => getActive(document) // Release the key automatically if it was pressed before. @@ -50,8 +50,8 @@ async function keyboardKeyAction( await keypress(keyDef, getCurrentElement, config) } - if (typeof delay === 'number' && i < repeat) { - await wait(delay) + if (i < repeat) { + await wait(config) } } diff --git a/src/options.ts b/src/options.ts index 8d91e879..1e970b0c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -120,6 +120,13 @@ export interface Options { * Defaults to `true` when calling the APIs per `setup`. */ writeToClipboard?: boolean + + /** + * A function to be called internally to advance your fake timers (if applicable) + * + * @example jest.advanceTimersByTime + */ + advanceTimers?: ((delay: number) => Promise) | ((delay: number) => void) } /** @@ -137,6 +144,7 @@ export const defaultOptionsDirect: Required = { skipClick: false, skipHover: false, writeToClipboard: false, + advanceTimers: () => Promise.resolve(), } /** diff --git a/src/pointer/pointerAction.ts b/src/pointer/pointerAction.ts index d5323c2e..7ca73410 100644 --- a/src/pointer/pointerAction.ts +++ b/src/pointer/pointerAction.ts @@ -37,10 +37,8 @@ export async function pointerAction(config: Config, actions: PointerAction[]) { ? pointerPress(config, {...action, target, coords}) : pointerMove(config, {...action, target, coords})) - if (typeof config.delay === 'number') { - if (i < actions.length - 1) { - await wait(config.delay) - } + if (i < actions.length - 1) { + await wait(config) } } diff --git a/src/utils/edit/input.ts b/src/utils/edit/input.ts index cc858626..2fbedbe0 100644 --- a/src/utils/edit/input.ts +++ b/src/utils/edit/input.ts @@ -1,4 +1,5 @@ import { + clearInitialValue, endTrackValue, getUIValue, setUIValue, @@ -128,7 +129,7 @@ function editInputElement( ) { let dataToInsert = data const spaceUntilMaxLength = getSpaceUntilMaxLength(element) - if (spaceUntilMaxLength !== undefined) { + if (spaceUntilMaxLength !== undefined && data.length > 0) { if (spaceUntilMaxLength > 0) { dataToInsert = data.substring(0, spaceUntilMaxLength) } else { @@ -169,6 +170,7 @@ function editInputElement( if (isValidDateOrTimeValue(element, newValue)) { commitInput(config, element, newOffset, {}) dispatchUIEvent(config, element, 'change') + clearInitialValue(element) } } else { commitInput(config, element, newOffset, { diff --git a/src/utils/misc/wait.ts b/src/utils/misc/wait.ts index 69ad59b5..aac99961 100644 --- a/src/utils/misc/wait.ts +++ b/src/utils/misc/wait.ts @@ -1,3 +1,12 @@ -export function wait(time?: number) { - return new Promise(resolve => setTimeout(() => resolve(), time)) +import {Config} from '../../setup' + +export function wait(config: Config) { + const delay = config.delay + if (typeof delay !== 'number') { + return + } + return Promise.all([ + new Promise(resolve => setTimeout(() => resolve(), delay)), + config.advanceTimers(delay), + ]) } diff --git a/tests/document/index.ts b/tests/document/index.ts index 72c68603..f6b6268f 100644 --- a/tests/document/index.ts +++ b/tests/document/index.ts @@ -50,7 +50,7 @@ test('keep track of value in UI', async () => { expect(getUIValue(element)).toBe('3.5') }) -test('trigger `change` event if value changed since focus/set', async () => { +test('trigger `change` event if value changed per user input', async () => { const {element, getEvents} = render( ``, {focus: false}, @@ -66,16 +66,25 @@ test('trigger `change` event if value changed since focus/set', async () => { expect(getEvents('change')).toHaveLength(0) element.focus() - // Programmatically changing value sets initial value + // Programmatically changing value is ignored element.value = '3' + // Value doesn't change setUIValue(element, '3') element.blur() expect(getEvents('change')).toHaveLength(0) element.focus() + setUIValue(element, '2') + // value is reset so there is no change in the end + element.value = '3' + element.blur() + + expect(getEvents('change')).toHaveLength(0) + + element.focus() + setUIValue(element, '2') element.value = '2' - setUIValue(element, '3') element.blur() expect(getEvents('change')).toHaveLength(1) @@ -112,6 +121,20 @@ test('maintain selection range on elements without support for selection range', expect(element.selectionStart).toBe(null) }) +test('reset UI selection if value is programmatically set', async () => { + const {element} = render(``) + + prepare(element) + + setUIValue(element, 'abc') + setUISelection(element, {anchorOffset: 1, focusOffset: 2}) + + element.value = 'abcdef' + expect(element.selectionStart).toBe(6) + expect(getUISelection(element)).toHaveProperty('focusOffset', 6) + expect(getUISelection(element)).toHaveProperty('startOffset', 6) +}) + test('clear UI selection if selection is programmatically set', async () => { const {element} = render(``) diff --git a/tests/utils/edit/input.ts b/tests/utils/edit/input.ts index ef146ca8..6ed3d3c6 100644 --- a/tests/utils/edit/input.ts +++ b/tests/utils/edit/input.ts @@ -235,10 +235,10 @@ test('prevent input on `beforeinput` event', () => { cases( 'maxlength', - ({html, data, expectedValue}) => { + ({html, data, inputType, expectedValue}) => { const {element, eventWasFired} = render(html) - input(createConfig(), element, data) + input(createConfig(), element, data, inputType) expect(element).toHaveValue(expectedValue) expect(eventWasFired('beforeinput')).toBe(true) @@ -270,6 +270,12 @@ cases( data: '', expectedValue: '', }, + 'delete data when maxlength is reached': { + html: ``, + data: '', + inputType: 'deleteContentForward', + expectedValue: 'oo', + }, }, ) diff --git a/tests/utils/misc/wait.ts b/tests/utils/misc/wait.ts new file mode 100644 index 00000000..3f88f9ce --- /dev/null +++ b/tests/utils/misc/wait.ts @@ -0,0 +1,9 @@ +import {wait} from '#src/utils/misc/wait' + +test('advances timers when set', async () => { + jest.useFakeTimers() + jest.setTimeout(50) + // If this wasn't advancing fake timers, we'd timeout and fail the test + await wait(10000, jest.advanceTimersByTime) + jest.useRealTimers() +})