From 57a6ea4d4808bb5c07817089c437929ba9cdb070 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 18 Oct 2021 20:03:59 +0200 Subject: [PATCH 01/84] docs: add patricklizon as a contributor for code (#749) * docs: update README.md * docs: update .all-contributorsrc Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 +++ 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 6950e596..0efa5c60 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -957,6 +957,15 @@ "contributions": [ "code" ] + }, + { + "login": "patricklizon", + "name": "Patrick LizoΕ„", + "avatar_url": "https://avatars.githubusercontent.com/u/12571855?v=4", + "profile": "https://github.com/patricklizon", + "contributions": [ + "code" + ] } ], "commitConvention": "none", diff --git a/README.md b/README.md index 35c3e0fd..4de0579a 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,9 @@ Thanks goes to these people ([emoji key][emojis]):
Johannes Fischer

πŸ’»
Andrew D.

πŸ’» + +
Patrick LizoΕ„

πŸ’» + From ce01cde601e7b7191055d0def319753bbab96493 Mon Sep 17 00:00:00 2001 From: Nick McCurdy Date: Fri, 15 Oct 2021 06:36:49 -0400 Subject: [PATCH 02/84] chore!: drop support for node 10 (#698) * feat: Drop support for node 10 * Update .codesandbox/ci.json Co-authored-by: Philipp Fritsche BREAKING CHANGE: Support for node 10 was removed as it reached its end-of-life. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a25d653..6d60e472 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "author": "Giorgio Polvara ", "license": "MIT", "engines": { - "node": ">=10", + "node": ">=12", "npm": ">=6" }, "repository": { From 719ba03af5647e09cd734f419ce5f234af60328a Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 19 Oct 2021 20:45:33 +0200 Subject: [PATCH 03/84] feat: add `setup` API (#746) --- src/__tests__/setup.ts | 273 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 29 ++--- src/keyboard/index.ts | 2 +- src/setup.ts | 187 ++++++++++++++++++++++++++++ src/tab.ts | 2 +- src/upload.ts | 2 +- 6 files changed, 470 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/setup.ts create mode 100644 src/setup.ts diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 00000000..14ee1f10 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,273 @@ +import userEvent from 'index' +import cases from 'jest-in-case' +import {UserEventApis} from '../setup' +import {setup} from './helpers/utils' + +/// start of mocking + +// The following hacky mocking allows us to spy on imported API functions. +// The API imports are replaced with a mock with the real API as implementation. +// This way we can run the real APIs here and without repeating tests of each API implementation, +// we still can test assertions on the wiring of arguments. + +// Disable eslint rules that are not worth it here as they heavily reduce readability +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable import/order */ + +// List of API modules imported by `setup` +import '../clear' +import '../click' +import '../hover' +import '../keyboard' +import '../paste' +import '../select-options' +import '../tab' +import '../type' +import '../upload' + +// `const` are not initialized when mocking is executed, but `function` are when prefixed with `mock` +function mockSpies() {} +type mockSpiesEntry = { + mock: jest.Mock + real: UserEventApis[T] +} + +// access the `function` as object +interface mockSpiesRefHack extends Record { + (): void +} +// make the tests more readable by applying the typecast here +function getSpy(k: keyof UserEventApis) { + return (mockSpies as mockSpiesRefHack)[k].mock +} +function getReal(k: keyof UserEventApis) { + return (mockSpies as mockSpiesRefHack)[k].real +} + +/** + * Mock an API module by replacing some of the exports with spies. + */ +function mockApis(modulePath: string, ...vars: string[]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const real = jest.requireActual(modulePath) + const fake: Record = {} + for (const key of vars) { + const mock = jest.fn() + ;(mockSpies as mockSpiesRefHack)[key as keyof UserEventApis] = { + mock, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + real: real[key], + } + fake[key] = mock + } + return { + __esmodule: true, + ...real, + ...fake, + } +} + +// List of API functions per module +jest.mock('../clear', () => mockApis('../clear', 'clear')) +jest.mock('../click', () => mockApis('../click', 'click', 'dblClick')) +jest.mock('../hover', () => mockApis('../hover', 'hover', 'unhover')) +jest.mock('../keyboard', () => mockApis('../keyboard', 'keyboard')) +jest.mock('../paste', () => mockApis('../paste', 'paste')) +jest.mock('../select-options', () => + mockApis('../select-options', 'selectOptions', 'deselectOptions'), +) +jest.mock('../tab', () => mockApis('../tab', 'tab')) +jest.mock('../type', () => mockApis('../type', 'type')) +jest.mock('../upload', () => mockApis('../upload', 'upload')) + +beforeEach(() => { + jest.resetAllMocks() + + // Apply the mock implementation. Any earlier implementation would be removed per `resetAllMocks`. + for (const key of Object.keys(mockSpies as mockSpiesRefHack)) { + getSpy(key as keyof UserEventApis).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-explicit-any + getReal(key as keyof UserEventApis) as any, + ) + } +}) + +/// end of mocking + +type APICase = { + api: T + args?: unknown[] + elementArg?: number + optionsArg?: number + options?: Record + optionsSub?: Record +} + +cases( + 'apply option defaults', + ({api, args = [], elementArg, optionsArg, options, optionsSub}) => { + const {element} = setup( + ['selectOptions', 'deselectOptions'].includes(api) + ? `` + : api === 'upload' + ? `` + : ``, + ) + element.focus() + + if (elementArg !== undefined) { + args[elementArg] = element + } + + const apis = userEvent.setup(options) + + ;(apis[api] as Function)(...args) + + const spy = getSpy(api) + expect(spy).toBeCalledTimes(1) + + // ensure that options are applied correctly + if (optionsArg !== undefined && options) { + expect(spy.mock.calls[0][optionsArg]).toEqual( + expect.objectContaining(options), + ) + } + + const subOptions = { + // just flip boolean values + ...Object.fromEntries( + Object.entries(options ?? {}).map(([key, value]) => [ + key, + typeof value === 'boolean' ? !value : value, + ]), + ), + ...optionsSub, + } + const subApis = apis.setup(subOptions) + + ;(subApis[api] as Function)(...args) + + expect(spy).toBeCalledTimes(2) + + // ensure that the new set of api receives different defaults + if (optionsArg !== undefined) { + expect(spy.mock.calls[1][optionsArg]).toEqual( + expect.objectContaining(subOptions), + ) + } + }, + { + clear: {api: 'clear', elementArg: 0}, + click: { + api: 'click', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + dblClick: { + api: 'dblClick', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + hover: { + api: 'hover', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + unhover: { + api: 'unhover', + elementArg: 0, + optionsArg: 2, + options: { + skipPointerEventsCheck: true, + }, + }, + keyboard: { + api: 'keyboard', + args: ['foo'], + optionsArg: 1, + options: { + keyboardMap: [{key: 'x', code: 'SpecialKey'}], + }, + optionsSub: { + keyboardMap: [{key: 'y', code: 'SpecialKey'}], + }, + }, + paste: {api: 'paste', args: [null, 'foo'], elementArg: 0}, + selectOptions: { + api: 'selectOptions', + args: [null, ['foo']], + elementArg: 0, + optionsArg: 3, + options: { + skipPointerEventsCheck: true, + }, + }, + deSelectOptions: { + api: 'deselectOptions', + args: [null, ['foo']], + elementArg: 0, + optionsArg: 3, + options: { + skipPointerEventsCheck: true, + }, + }, + tab: { + api: 'tab', + optionsArg: 0, + options: { + focusTrap: document.querySelector('body'), + }, + }, + type: { + api: 'type', + args: [null, 'foo'], + elementArg: 0, + optionsArg: 2, + options: { + skipClick: true, + }, + }, + upload: { + api: 'upload', + elementArg: 0, + optionsArg: 3, + options: { + applyAccept: true, + }, + }, + }, +) + +test('maintain `keyboardState` through different api calls', async () => { + const {element, getEvents} = setup(``) + element.focus() + + const api = userEvent.setup() + + expect(api.keyboard('{a>}{b>}')).toBe(undefined) + + expect(getSpy('keyboard')).toBeCalledTimes(1) + + expect(element).toHaveValue('ab') + expect(getEvents('keyup')).toHaveLength(0) + + await expect(api.keyboard('{/a}', {delay: 1})).resolves.toBe(undefined) + + expect(element).toHaveValue('ab') + expect(getEvents('keyup')).toHaveLength(1) + + api.setup({}).keyboard('b') + + expect(element).toHaveValue('abb') + // if the state is shared through api the already pressed `b` is automatically released + expect(getEvents('keyup')).toHaveLength(3) +}) diff --git a/src/index.ts b/src/index.ts index 1d241393..6b4c0820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,11 @@ -import {click, dblClick} from './click' -import {type} from './type' -import {clear} from './clear' -import {tab} from './tab' -import {hover, unhover} from './hover' -import {upload} from './upload' -import {selectOptions, deselectOptions} from './select-options' -import {paste} from './paste' -import {keyboard, specialCharMap} from './keyboard' +import {specialCharMap} from './keyboard' +import {userEventApis, UserEventApis, setup} from './setup' -const userEvent = { - click, - dblClick, - type, - clear, - tab, - hover, - unhover, - upload, - selectOptions, - deselectOptions, - paste, - keyboard, +const userEvent: UserEventApis & { + setup: typeof setup +} = { + ...userEventApis, + setup, } export default userEvent diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 36827e3d..51822658 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -59,7 +59,7 @@ export function keyboardImplementationWrapper( } } -function createKeyboardState(): keyboardState { +export function createKeyboardState(): keyboardState { return { activeElement: null, pressed: [], diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 00000000..b89eaea5 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,187 @@ +import {clear} from 'clear' +import {click, clickOptions, dblClick} from 'click' +import {hover, unhover} from 'hover' +import {createKeyboardState, keyboard, keyboardOptions} from 'keyboard' +import type {keyboardState} from 'keyboard/types' +import {paste} from 'paste' +import {deselectOptions, selectOptions} from 'select-options' +import {tab, tabOptions} from 'tab' +import {type} from 'type' +import {typeOptions} from 'type/typeImplementation' +import {upload, uploadOptions} from 'upload' +import {PointerOptions} from 'utils' + +export const userEventApis = { + clear, + click, + dblClick, + deselectOptions, + hover, + keyboard, + paste, + selectOptions, + tab, + type, + unhover, + upload, +} +export type UserEventApis = typeof userEventApis + +type ClickOptions = Omit + +type KeyboardOptions = Partial + +type TabOptions = Omit + +type TypeOptions = Omit< + typeOptions, + 'initialSelectionStart' | 'initialSelectionEnd' +> + +type UploadOptions = uploadOptions + +interface SetupOptions + extends ClickOptions, + KeyboardOptions, + PointerOptions, + TabOptions, + TypeOptions, + UploadOptions {} + +/** + * Start a "session" with userEvent. + * All APIs returned by this function share an input device state and a default configuration. + */ +export function setup(options: SetupOptions = {}) { + // TODO: prepare our document state workarounds + + return _setup(options, { + keyboardState: createKeyboardState(), + }) +} + +function _setup( + { + applyAccept, + autoModify, + delay, + document, + focusTrap, + keyboardMap, + skipAutoClose, + skipClick, + skipHover, + skipPointerEventsCheck = false, + }: SetupOptions, + { + keyboardState, + }: { + keyboardState: keyboardState + }, +): UserEventApis & { + /** + * Create a set of callbacks with different default settings but the same state. + */ + setup(options: SetupOptions): ReturnType +} { + const keyboardDefaults: KeyboardOptions = { + autoModify, + delay, + document, + keyboardMap, + } + const pointerDefaults: PointerOptions = { + skipPointerEventsCheck, + } + const clickDefaults: clickOptions = { + skipHover, + } + const tabDefaults: TabOptions = { + focusTrap, + } + const typeDefaults: TypeOptions = { + delay, + skipAutoClose, + skipClick, + } + const uploadDefaults: UploadOptions = { + applyAccept, + } + + return { + clear, + + click: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...clickDefaults, ...args[2]} + return click(...args) + }, + + dblClick: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...clickDefaults, ...args[2]} + return dblClick(...args) + }, + + deselectOptions: (...args: Parameters) => { + args[3] = {...pointerDefaults, ...args[3]} + return deselectOptions(...args) + }, + + hover: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...args[2]} + return hover(...args) + }, + + // keyboard needs typecasting because of the overloading + keyboard: ((...args: Parameters) => { + args[1] = {...keyboardDefaults, ...args[1], keyboardState} + const ret = keyboard(...args) as keyboardState | Promise + if (ret instanceof Promise) { + return ret.then(() => undefined) + } + }) as typeof keyboard, + + paste: (...args: Parameters) => { + return paste(...args) + }, + + selectOptions: (...args: Parameters) => { + args[3] = {...pointerDefaults, ...args[3]} + return selectOptions(...args) + }, + + setup: (options: SetupOptions) => { + return _setup( + { + ...keyboardDefaults, + ...pointerDefaults, + ...clickDefaults, + ...options, + }, + { + keyboardState, + }, + ) + }, + + tab: (...args: Parameters) => { + args[0] = {...tabDefaults, ...args[0]} + return tab(...args) + }, + + // type needs typecasting because of the overloading + type: ((...args: Parameters) => { + args[2] = {...typeDefaults, ...args[2]} + return type(...args) + }) as typeof type, + + unhover: (...args: Parameters) => { + args[2] = {...pointerDefaults, ...args[2]} + return unhover(...args) + }, + + upload: (...args: Parameters) => { + args[3] = {...uploadDefaults, ...args[3]} + return upload(...args) + }, + } +} diff --git a/src/tab.ts b/src/tab.ts index 50680199..41c18061 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -28,7 +28,7 @@ function getNextElement( return elements[nextIndex] || elements[defaultIndex] } -interface tabOptions { +export interface tabOptions { shift?: boolean focusTrap?: Document | Element } diff --git a/src/upload.ts b/src/upload.ts index 5837ae26..b4350ebd 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -9,7 +9,7 @@ interface uploadInit { changeInit?: EventInit } -interface uploadOptions { +export interface uploadOptions { applyAccept?: boolean } From 73e62d03877c736e18bc9e394e4ed07333b99933 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 19 Oct 2021 20:46:18 +0200 Subject: [PATCH 04/84] feat: keep track of document state in UI (#747) * programmatically changing value resets UIValue * prevent stacking of value interceptors * programmatically changing value resets initial value * intercept calls to `setSelectionRange` --- src/__tests__/document/index.ts | 94 ++++++++++++ .../edit => document}/selectionRange.ts | 0 src/__tests__/keyboard/plugin/character.ts | 17 ++- src/__tests__/type.js | 5 +- src/document/applyNative.ts | 28 ++++ src/document/index.ts | 79 ++++++++++ src/document/interceptor.ts | 58 ++++++++ src/document/selection.ts | 86 +++++++++++ src/document/value.ts | 66 +++++++++ src/keyboard/index.ts | 3 + src/keyboard/plugins/character.ts | 16 +- src/keyboard/plugins/control.ts | 8 +- src/keyboard/plugins/functional.ts | 8 +- src/keyboard/plugins/index.ts | 9 +- src/keyboard/shared/carryValue.ts | 14 -- src/keyboard/shared/fireInputEvent.ts | 140 ------------------ src/keyboard/shared/index.ts | 2 - src/keyboard/types.ts | 3 + src/type/index.ts | 3 + src/utils/edit/fireInputEvent.ts | 65 ++++++++ src/utils/edit/getValue.ts | 6 +- src/utils/edit/hasUnreliableEmptyValue.ts | 21 --- src/utils/edit/selectionRange.ts | 74 ++------- src/utils/index.ts | 2 +- 24 files changed, 524 insertions(+), 283 deletions(-) create mode 100644 src/__tests__/document/index.ts rename src/__tests__/{utils/edit => document}/selectionRange.ts (100%) create mode 100644 src/document/applyNative.ts create mode 100644 src/document/index.ts create mode 100644 src/document/interceptor.ts create mode 100644 src/document/selection.ts create mode 100644 src/document/value.ts delete mode 100644 src/keyboard/shared/carryValue.ts delete mode 100644 src/keyboard/shared/fireInputEvent.ts create mode 100644 src/utils/edit/fireInputEvent.ts delete mode 100644 src/utils/edit/hasUnreliableEmptyValue.ts 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 `) diff --git a/src/__tests__/keyboard/plugin/control.ts b/tests/keyboard/plugin/control.ts similarity index 95% rename from src/__tests__/keyboard/plugin/control.ts rename to tests/keyboard/plugin/control.ts index 99699fa4..8d1dbfdf 100644 --- a/src/__tests__/keyboard/plugin/control.ts +++ b/tests/keyboard/plugin/control.ts @@ -1,5 +1,5 @@ -import userEvent from 'index' -import {setup} from '__tests__/helpers/utils' +import userEvent from '#src' +import {setup} from '#testHelpers/utils' test('press [Home] in textarea', () => { const {element} = setup( diff --git a/src/__tests__/keyboard/plugin/functional.ts b/tests/keyboard/plugin/functional.ts similarity index 98% rename from src/__tests__/keyboard/plugin/functional.ts rename to tests/keyboard/plugin/functional.ts index 69a11895..c47316c1 100644 --- a/src/__tests__/keyboard/plugin/functional.ts +++ b/tests/keyboard/plugin/functional.ts @@ -1,5 +1,5 @@ -import userEvent from 'index' -import {setup} from '__tests__/helpers/utils' +import userEvent from '#src' +import {setup} from '#testHelpers/utils' test('produce extra events for the Control key when AltGraph is pressed', () => { const {element, getEventSnapshot} = setup(``) diff --git a/src/__tests__/keyboard/shared/fireInputEvent.ts b/tests/keyboard/shared/fireInputEvent.ts similarity index 86% rename from src/__tests__/keyboard/shared/fireInputEvent.ts rename to tests/keyboard/shared/fireInputEvent.ts index e5adfac9..1de8b152 100644 --- a/src/__tests__/keyboard/shared/fireInputEvent.ts +++ b/tests/keyboard/shared/fireInputEvent.ts @@ -1,5 +1,5 @@ -import {setup} from '__tests__/helpers/utils' -import userEvent from '../../../' +import {setup} from '#testHelpers/utils' +import userEvent from '#src' it('dispatch change event on blur', () => { const {element, getEvents} = setup('') diff --git a/src/__tests__/paste.js b/tests/paste.js similarity index 97% rename from src/__tests__/paste.js rename to tests/paste.js index 22ab1e3d..f4a0fc21 100644 --- a/src/__tests__/paste.js +++ b/tests/paste.js @@ -1,5 +1,5 @@ -import userEvent from '../' -import {setup} from './helpers/utils' +import userEvent from '#src' +import {setup} from '#testHelpers/utils' test('should paste text in input', () => { const {element, getEventSnapshot} = setup('') diff --git a/src/__tests__/pointer/index.ts b/tests/pointer/index.ts similarity index 99% rename from src/__tests__/pointer/index.ts rename to tests/pointer/index.ts index a5e570bd..f62e8845 100644 --- a/src/__tests__/pointer/index.ts +++ b/tests/pointer/index.ts @@ -1,6 +1,6 @@ -import userEvent from '../../index' -import {wait} from '../../utils' -import {setup} from '../helpers/utils' +import userEvent from '#src' +import {wait} from '#src/utils' +import {setup} from '#testHelpers/utils' test('double click', () => { const {element, getClickEventsSnapshot} = setup(`
`) diff --git a/tests/react/keyboard.tsx b/tests/react/keyboard.tsx new file mode 100644 index 00000000..7b1d6d2c --- /dev/null +++ b/tests/react/keyboard.tsx @@ -0,0 +1,20 @@ +import React, {useState} from 'react' +import {render, screen} from '@testing-library/react' +import userEvent from '#src' + +test('maintain cursor position on controlled input', () => { + function Input({initialValue}: {initialValue: string}) { + const [val, setVal] = useState(initialValue) + + return setVal(e.target.value)} /> + } + + render() + screen.getByRole('textbox').focus() + ;(screen.getByRole('textbox') as HTMLInputElement).setSelectionRange(1, 1) + userEvent.keyboard('b') + + expect(screen.getByRole('textbox')).toHaveValue('abcd') + expect(screen.getByRole('textbox')).toHaveProperty('selectionStart', 2) + expect(screen.getByRole('textbox')).toHaveProperty('selectionEnd', 2) +}) diff --git a/src/__tests__/react/tsconfig.json b/tests/react/tsconfig.json similarity index 56% rename from src/__tests__/react/tsconfig.json rename to tests/react/tsconfig.json index 014cc675..7319adb8 100644 --- a/src/__tests__/react/tsconfig.json +++ b/tests/react/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { "jsx": "react" } diff --git a/src/__tests__/react/type.tsx b/tests/react/type.tsx similarity index 93% rename from src/__tests__/react/type.tsx rename to tests/react/type.tsx index 917079e1..a7873c95 100644 --- a/src/__tests__/react/type.tsx +++ b/tests/react/type.tsx @@ -1,6 +1,6 @@ import React from 'react' import {render, screen} from '@testing-library/react' -import userEvent from 'index' +import userEvent from '#src' test('trigger onChange SyntheticEvent on input', () => { const inputHandler = jest.fn() diff --git a/src/__tests__/deselect-options.js b/tests/selectOptions/deselect.js similarity index 97% rename from src/__tests__/deselect-options.js rename to tests/selectOptions/deselect.js index 6a9d5846..c8f40c3b 100644 --- a/src/__tests__/deselect-options.js +++ b/tests/selectOptions/deselect.js @@ -1,5 +1,5 @@ -import userEvent from '../' -import {addListeners, setupSelect, setup} from './helpers/utils' +import userEvent from '#src' +import {addListeners, setupSelect, setup} from '#testHelpers/utils' test('fires correct events', () => { const {form, select, options, getEventSnapshot} = setupSelect({ diff --git a/src/__tests__/select-options.js b/tests/selectOptions/select.js similarity index 99% rename from src/__tests__/select-options.js rename to tests/selectOptions/select.js index accfa5d9..a5834788 100644 --- a/src/__tests__/select-options.js +++ b/tests/selectOptions/select.js @@ -1,5 +1,10 @@ -import userEvent from '../' -import {setupSelect, addListeners, setupListbox, setup} from './helpers/utils' +import userEvent from '#src' +import { + setupSelect, + addListeners, + setupListbox, + setup, +} from '#testHelpers/utils' test('fires correct events', () => { const {select, options, getEventSnapshot} = setupSelect() diff --git a/src/__tests__/setup.ts b/tests/setup.ts similarity index 88% rename from src/__tests__/setup.ts rename to tests/setup.ts index c3266d8f..3ab55c92 100644 --- a/src/__tests__/setup.ts +++ b/tests/setup.ts @@ -1,7 +1,7 @@ -import userEvent from 'index' import cases from 'jest-in-case' -import {UserEventApis} from '../setup' -import {setup} from './helpers/utils' +import userEvent from '#src' +import {UserEventApis} from '#src/setup' +import {setup} from '#testHelpers/utils' /// start of mocking @@ -15,16 +15,16 @@ import {setup} from './helpers/utils' /* eslint-disable import/order */ // List of API modules imported by `setup` -import '../clear' -import '../click' -import '../hover' -import '../keyboard' -import '../paste' -import '../pointer' -import '../select-options' -import '../tab' -import '../type' -import '../upload' +import '#src/clear' +import '#src/click' +import '#src/hover' +import '#src/keyboard' +import '#src/paste' +import '#src/pointer' +import '#src/selectOptions' +import '#src/tab' +import '#src/type' +import '#src/upload' // `const` are not initialized when mocking is executed, but `function` are when prefixed with `mock` function mockSpies() {} @@ -69,18 +69,18 @@ function mockApis(modulePath: string, ...vars: string[]) { } // List of API functions per module -jest.mock('../clear', () => mockApis('../clear', 'clear')) -jest.mock('../click', () => mockApis('../click', 'click', 'dblClick')) -jest.mock('../hover', () => mockApis('../hover', 'hover', 'unhover')) -jest.mock('../keyboard', () => mockApis('../keyboard', 'keyboard')) -jest.mock('../paste', () => mockApis('../paste', 'paste')) -jest.mock('../pointer', () => mockApis('../pointer', 'pointer')) -jest.mock('../select-options', () => - mockApis('../select-options', 'selectOptions', 'deselectOptions'), +jest.mock('#src/clear', () => mockApis('#src/clear', 'clear')) +jest.mock('#src/click', () => mockApis('#src/click', 'click', 'dblClick')) +jest.mock('#src/hover', () => mockApis('#src/hover', 'hover', 'unhover')) +jest.mock('#src/keyboard', () => mockApis('#src/keyboard', 'keyboard')) +jest.mock('#src/paste', () => mockApis('#src/paste', 'paste')) +jest.mock('#src/pointer', () => mockApis('#src/pointer', 'pointer')) +jest.mock('#src/selectOptions', () => + mockApis('#src/selectOptions', 'selectOptions', 'deselectOptions'), ) -jest.mock('../tab', () => mockApis('../tab', 'tab')) -jest.mock('../type', () => mockApis('../type', 'type')) -jest.mock('../upload', () => mockApis('../upload', 'upload')) +jest.mock('#src/tab', () => mockApis('#src/tab', 'tab')) +jest.mock('#src/type', () => mockApis('#src/type', 'type')) +jest.mock('#src/upload', () => mockApis('#src/upload', 'upload')) beforeEach(() => { jest.resetAllMocks() diff --git a/src/__tests__/tab.js b/tests/tab.js similarity index 97% rename from src/__tests__/tab.js rename to tests/tab.js index fbd0493d..777552a8 100644 --- a/src/__tests__/tab.js +++ b/tests/tab.js @@ -1,6 +1,6 @@ -import userEvent from '../' -import {focus} from '../focus' -import {setup, addListeners} from './helpers/utils' +import userEvent from '#src' +import {focus} from '#src/utils' +import {setup, addListeners} from '#testHelpers/utils' test('fires events when tabbing between two elements', () => { const {element, getEventSnapshot, clearEventCalls} = setup( @@ -324,14 +324,8 @@ test('should stay within a focus trap', () => { document.querySelector('[data-testid="div1"]'), document.querySelector('[data-testid="div2"]'), ] - const [ - checkbox1, - radio1, - number1, - checkbox2, - radio2, - number2, - ] = document.querySelectorAll('[data-testid="element"]') + const [checkbox1, radio1, number1, checkbox2, radio2, number2] = + document.querySelectorAll('[data-testid="element"]') expect(document.body).toHaveFocus() diff --git a/src/__tests__/type.js b/tests/type/index.js similarity index 99% rename from src/__tests__/type.js rename to tests/type/index.js index 11ccf665..e5ff2bc5 100644 --- a/src/__tests__/type.js +++ b/tests/type/index.js @@ -1,7 +1,7 @@ -import userEvent from '../' -import {wait} from '../utils' -import {setup, addListeners} from './helpers/utils' -import './helpers/custom-element' +import userEvent from '#src' +import {wait} from '#src/utils' +import {setup, addListeners} from '#testHelpers/utils' +import '#testHelpers/custom-element' test('types text in input', () => { const {element, getEventSnapshot} = setup('') diff --git a/src/__tests__/type-modifiers.js b/tests/type/modifiers.js similarity index 99% rename from src/__tests__/type-modifiers.js rename to tests/type/modifiers.js index 3dc62171..1be10468 100644 --- a/src/__tests__/type-modifiers.js +++ b/tests/type/modifiers.js @@ -1,5 +1,5 @@ -import userEvent from '../' -import {setup} from './helpers/utils' +import userEvent from '#src' +import {setup} from '#testHelpers/utils' // Note, use the setup function at the bottom of the file... // but don't hurt yourself trying to read it πŸ˜… diff --git a/src/__tests__/upload.js b/tests/upload.js similarity index 98% rename from src/__tests__/upload.js rename to tests/upload.js index c1315d24..1a9e051e 100644 --- a/src/__tests__/upload.js +++ b/tests/upload.js @@ -1,5 +1,5 @@ -import userEvent from '../' -import {setup, addListeners} from './helpers/utils' +import userEvent from '#src' +import {setup, addListeners} from '#testHelpers/utils' test('should fire the correct events for input', () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) diff --git a/src/__tests__/utils/edit/calculateNewValue.ts b/tests/utils/edit/calculateNewValue.ts similarity index 98% rename from src/__tests__/utils/edit/calculateNewValue.ts rename to tests/utils/edit/calculateNewValue.ts index 3ee94cb3..c24cdc1b 100644 --- a/src/__tests__/utils/edit/calculateNewValue.ts +++ b/tests/utils/edit/calculateNewValue.ts @@ -1,5 +1,5 @@ -import userEvent from 'index' -import {setup} from '__tests__/helpers/utils' +import userEvent from '#src' +import {setup} from '#testHelpers/utils' // TODO: focus the maxlength tests on the tested aspects diff --git a/src/__tests__/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts similarity index 81% rename from src/__tests__/utils/edit/isContentEditable.ts rename to tests/utils/edit/isContentEditable.ts index 5f186a59..3d2885a3 100644 --- a/src/__tests__/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -1,5 +1,5 @@ -import {setup} from '__tests__/helpers/utils' -import {isContentEditable} from '../../../utils' +import {setup} from '#testHelpers/utils' +import {isContentEditable} from '#src/utils' test('report if element is contenteditable', () => { const {elements} = setup( diff --git a/src/__tests__/blur.js b/tests/utils/focus/blur.js similarity index 83% rename from src/__tests__/blur.js rename to tests/utils/focus/blur.js index c4035cac..a92d8e60 100644 --- a/src/__tests__/blur.js +++ b/tests/utils/focus/blur.js @@ -1,6 +1,5 @@ -import {blur} from '../blur' -import {focus} from '../focus' -import {setup} from './helpers/utils' +import {blur, focus} from '#src/utils' +import {setup} from '#testHelpers/utils' test('blur a button', () => { const {element, getEventSnapshot, clearEventCalls} = setup(``) - const input = element.children[0] - const button = element.children[1] + const input = element.children[0] as HTMLInputElement + const button = element.children[1] as HTMLButtonElement - addEventListener(button, 'click', () => input.focus()) + addEventListener(button, 'click', async () => input.focus()) expect(input).not.toHaveFocus() - userEvent.click(button) + await userEvent.click(button) expect(input).toHaveFocus() - userEvent.click(button) + await userEvent.click(button) expect(input).toHaveFocus() }) -test('gives focus to the form control when clicking the label', () => { +test('gives focus to the form control when clicking the label', async () => { const {element} = setup(`
@@ -258,11 +258,11 @@ test('gives focus to the form control when clicking the label', () => { const label = element.children[0] const input = element.children[1] - userEvent.click(label) + await userEvent.click(label) expect(input).toHaveFocus() }) -test('gives focus to the form control when clicking within a label', () => { +test('gives focus to the form control when clicking within a label', async () => { const {element} = setup(`
@@ -270,16 +270,16 @@ test('gives focus to the form control when clicking within a label', () => {
`) const label = element.children[0] - const span = label.firstChild + const span = label.children[0] const input = element.children[1] - userEvent.click(span) + await userEvent.click(span) expect(input).toHaveFocus() }) -test('fires no events when clicking a label with a nested control that is disabled', () => { +test('fires no events when clicking a label with a nested control that is disabled', async () => { const {element, getEventSnapshot} = setup(``) - userEvent.click(element) + await userEvent.click(element) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: label @@ -297,12 +297,12 @@ test('fires no events when clicking a label with a nested control that is disabl `) }) -test('does not crash if the label has no control', () => { +test('does not crash if the label has no control', async () => { const {element} = setup(``) - userEvent.click(element) + await userEvent.click(element) }) -test('clicking a label checks the checkbox', () => { +test('clicking a label checks the checkbox', async () => { const {element} = setup(`
@@ -312,12 +312,12 @@ test('clicking a label checks the checkbox', () => { const label = element.children[0] const input = element.children[1] - userEvent.click(label) + await userEvent.click(label) expect(input).toHaveFocus() expect(input).toBeChecked() }) -test('clicking a label checks the radio', () => { +test('clicking a label checks the radio', async () => { const {element} = setup(`
@@ -327,50 +327,50 @@ test('clicking a label checks the radio', () => { const label = element.children[0] const input = element.children[1] - userEvent.click(label) + await userEvent.click(label) expect(input).toHaveFocus() expect(input).toBeChecked() }) -test('submits a form when clicking on a `) - userEvent.click(element.children[0]) + await userEvent.click(element.children[0]) expect(eventWasFired('submit')).toBe(true) }) -test('does not submit a form when clicking on a `) - userEvent.click(element.children[0]) + await userEvent.click(element.children[0]) expect(getEventSnapshot()).not.toContain('submit') }) -test('does not fire blur on current element if is the same as previous', () => { +test('does not fire blur on current element if is the same as previous', async () => { const {element, getEventSnapshot, clearEventCalls} = setup('`) - userEvent.tab() + await userEvent.tab() expect(document.body).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(document.body).toHaveFocus() }) -test('skip consecutive radios of same group', () => { +test('skip consecutive radios of same group', async () => { const { elements: [inputA, radioA, radioB, inputB, radioC, radioD, radioE, inputC], } = setup(` @@ -474,32 +476,32 @@ test('skip consecutive radios of same group', () => { inputA.focus() - userEvent.tab() + await userEvent.tab() expect(radioA).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputB).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(radioC).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(radioD).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputC).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(radioE).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(radioC).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(inputB).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(radioB).toHaveFocus() - userEvent.tab({shift: true}) + await userEvent.tab({shift: true}) expect(inputA).toHaveFocus() }) -test('skip unchecked radios if that group has a checked one', () => { +test('skip unchecked radios if that group has a checked one', async () => { const { elements: [inputA, , inputB, radioB, inputC, , inputD], } = setup(` @@ -514,17 +516,17 @@ test('skip unchecked radios if that group has a checked one', () => { inputA.focus() - userEvent.tab() + await userEvent.tab() expect(inputB).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(radioB).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputC).toHaveFocus() - userEvent.tab() + await userEvent.tab() expect(inputD).toHaveFocus() }) -test('tab from active radio when another one is checked', () => { +test('tab from active radio when another one is checked', async () => { const { elements: [, , , radioB, inputC], } = setup(` @@ -537,12 +539,12 @@ test('tab from active radio when another one is checked', () => { radioB.focus() - userEvent.tab() + await userEvent.tab() expect(inputC).toHaveFocus() }) -test('calls FocusEvents with relatedTarget', () => { +test('calls FocusEvents with relatedTarget', async () => { const { elements: [element0, element1], } = setup('') @@ -551,12 +553,14 @@ test('calls FocusEvents with relatedTarget', () => { const events0 = addListeners(element0) const events1 = addListeners(element1) - userEvent.tab() + await userEvent.tab() - expect(events0.getEvents().find(e => e.type === 'blur').relatedTarget).toBe( - element1, - ) - expect(events1.getEvents().find(e => e.type === 'focus').relatedTarget).toBe( - element0, - ) + expect( + events0.getEvents().find((e): e is FocusEvent => e.type === 'blur') + ?.relatedTarget, + ).toBe(element1) + expect( + events1.getEvents().find((e): e is FocusEvent => e.type === 'focus') + ?.relatedTarget, + ).toBe(element0) }) diff --git a/tests/type/index.js b/tests/type/index.ts similarity index 85% rename from tests/type/index.js rename to tests/type/index.ts index f12c23f1..bb224ba7 100644 --- a/tests/type/index.js +++ b/tests/type/index.ts @@ -1,11 +1,10 @@ import userEvent from '#src' -import {wait} from '#src/utils' import {setup, addListeners} from '#testHelpers/utils' import '#testHelpers/custom-element' -test('types text in input', () => { +test('types text in input', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'Sup') + await userEvent.type(element, 'Sup') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Sup"] @@ -37,11 +36,11 @@ test('types text in input', () => { `) }) -test('can skip the initial click', () => { +test('can skip the initial click', async () => { const {element, getEventSnapshot, clearEventCalls} = setup('') element.focus() // users MUST focus themselves if they wish to skip the click clearEventCalls() - userEvent.type(element, 'Sup', {skipClick: true}) + await userEvent.type(element, 'Sup', {skipClick: true}) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Sup"] @@ -60,13 +59,15 @@ test('can skip the initial click', () => { `) }) -test('types text inside custom element', () => { +test('types text inside custom element', async () => { const element = document.createElement('custom-el') document.body.append(element) - const inputEl = element.shadowRoot.querySelector('input') + const inputEl = (element.shadowRoot as ShadowRoot).querySelector( + 'input', + ) as HTMLInputElement const {getEventSnapshot} = addListeners(inputEl) - userEvent.type(inputEl, 'Sup') + await userEvent.type(inputEl, 'Sup') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Sup"] @@ -98,9 +99,9 @@ test('types text inside custom element', () => { `) }) -test('types text in textarea', () => { +test('types text in textarea', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'Sup') + await userEvent.type(element, 'Sup') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="Sup"] @@ -132,12 +133,12 @@ test('types text in textarea', () => { `) }) -test('does not fire input event when keypress calls prevent default', () => { +test('does not fire input event when keypress calls prevent default', async () => { const {element, getEventSnapshot} = setup('', { eventHandlers: {keyPress: e => e.preventDefault()}, }) - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -160,12 +161,12 @@ test('does not fire input event when keypress calls prevent default', () => { `) }) -test('does not fire keypress or input events when keydown calls prevent default', () => { +test('does not fire keypress or input events when keydown calls prevent default', async () => { const {element, getEventSnapshot} = setup('', { eventHandlers: {keyDown: e => e.preventDefault()}, }) - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -187,19 +188,19 @@ test('does not fire keypress or input events when keydown calls prevent default' `) }) -test('does not fire events when disabled', () => { +test('does not fire events when disabled', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot( `No events were fired on: input[value=""]`, ) }) -test('does not fire input when readonly', () => { +test('does not fire input when readonly', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, 'a') + await userEvent.type(element, 'a') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value=""] @@ -225,14 +226,16 @@ test('does not fire input when readonly', () => { test('should delay the typing when opts.delay is not 0', async () => { const inputValues = [{timestamp: Date.now(), value: ''}] const onInput = jest.fn(event => { - inputValues.push({timestamp: Date.now(), value: event.target.value}) + inputValues.push({ + timestamp: Date.now(), + value: ((event as InputEvent).target as HTMLInputElement).value, + }) }) const {element} = setup('', {eventHandlers: {input: onInput}}) const text = 'Hello, world!' const delay = 10 - // eslint-disable-next-line testing-library/no-await-sync-events await userEvent.type(element, text, {delay}) expect(onInput).toHaveBeenCalledTimes(text.length) @@ -245,13 +248,13 @@ test('should delay the typing when opts.delay is not 0', async () => { } }) -test('should fire events on the currently focused element', () => { +test('should fire events on the currently focused element', async () => { const {element} = setup(`
`, { eventHandlers: {keyDown: handleKeyDown}, }) - const input1 = element.children[0] - const input2 = element.children[1] + const input1 = element.children[0] as HTMLInputElement + const input2 = element.children[1] as HTMLInputElement const text = 'Hello, world!' const changeFocusLimit = 7 @@ -261,43 +264,45 @@ test('should fire events on the currently focused element', () => { } } - userEvent.type(input1, text) + await userEvent.type(input1, text) expect(input1).toHaveValue(text.slice(0, changeFocusLimit)) expect(input2).toHaveValue(text.slice(changeFocusLimit)) expect(input2).toHaveFocus() }) -test('should replace selected text', () => { +test('should replace selected text', async () => { const {element} = setup('') - userEvent.type(element, 'friend', { + await userEvent.type(element, 'friend', { initialSelectionStart: 6, initialSelectionEnd: 11, }) expect(element).toHaveValue('hello friend') }) -test('does not continue firing events when disabled during typing', () => { +test('does not continue firing events when disabled during typing', async () => { const {element} = setup('', { - eventHandlers: {input: e => (e.target.disabled = true)}, + eventHandlers: { + input: e => ((e.target as HTMLInputElement).disabled = true), + }, }) - userEvent.type(element, 'hi') + await userEvent.type(element, 'hi') expect(element).toHaveValue('h') }) // https://github.com/testing-library/user-event/issues/346 -test('typing in an empty textarea', () => { +test('typing in an empty textarea', async () => { const {element} = setup('') - userEvent.type(element, '1234') + await userEvent.type(element, '1234') expect(element).toHaveValue('1234') }) // https://github.com/testing-library/user-event/issues/321 -test('typing in a textarea with existing text', () => { +test('typing in a textarea with existing text', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '12') + await userEvent.type(element, '12') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="Hello, 12"] @@ -328,11 +333,13 @@ test('typing in a textarea with existing text', () => { }) // https://github.com/testing-library/user-event/issues/321 -test('accepts an initialSelectionStart and initialSelectionEnd', () => { - const {element, getEventSnapshot} = setup('') +test('accepts an initialSelectionStart and initialSelectionEnd', async () => { + const {element, getEventSnapshot} = setup( + '', + ) element.setSelectionRange(0, 0) - userEvent.type(element, '12', { + await userEvent.type(element, '12', { initialSelectionStart: element.selectionStart, initialSelectionEnd: element.selectionEnd, }) @@ -370,17 +377,17 @@ test('accepts an initialSelectionStart and initialSelectionEnd', () => { }) // https://github.com/testing-library/user-event/issues/316#issuecomment-640199908 -test('can type into an input with type `email`', () => { +test('can type into an input with type `email`', async () => { const {element} = setup('') const email = 'yo@example.com' - userEvent.type(element, email) + await userEvent.type(element, email) expect(element).toHaveValue(email) }) -test('can type into an input with type `date`', () => { +test('can type into an input with type `date`', async () => { const {element, getEventSnapshot} = setup('') const date = '2020-06-29' - userEvent.type(element, date) + await userEvent.type(element, date) expect(element).toHaveValue(date) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="2020-06-29"] @@ -434,10 +441,10 @@ test('can type into an input with type `date`', () => { }) // https://github.com/testing-library/user-event/issues/336 -test('can type "-" into number inputs', () => { +test('can type "-" into number inputs', async () => { const {element, getEventSnapshot} = setup('') const negativeNumber = '-3' - userEvent.type(element, negativeNumber) + await userEvent.type(element, negativeNumber) expect(element).toHaveValue(-3) // NOTE: the input event here does not actually change the value thanks to @@ -471,9 +478,9 @@ test('can type "-" into number inputs', () => { }) // https://github.com/testing-library/user-event/issues/336 -test('can type "." into number inputs', () => { +test('can type "." into number inputs', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '3.3') + await userEvent.type(element, '3.3') expect(element).toHaveValue(3.3) expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -507,23 +514,23 @@ test('can type "." into number inputs', () => { `) }) -test('-{backspace}3', () => { +test('-{backspace}3', async () => { const {element} = setup('') const negativeNumber = '-{backspace}3' - userEvent.type(element, negativeNumber) + await userEvent.type(element, negativeNumber) expect(element).toHaveValue(3) }) -test('-a3', () => { +test('-a3', async () => { const {element} = setup('') const negativeNumber = '-a3' - userEvent.type(element, negativeNumber) + await userEvent.type(element, negativeNumber) expect(element).toHaveValue(-3) }) -test('typing an invalid input value', () => { - const {element} = setup('') - userEvent.type(element, '3-3') +test('typing an invalid input value', async () => { + const {element} = setup('') + await userEvent.type(element, '3-3') expect(element).toHaveValue(null) @@ -534,59 +541,59 @@ test('typing an invalid input value', () => { expect(element.validity.badInput).toBe(false) }) -test('should not throw error if we are trying to call type on an element without a value', () => { +test('should not throw error if we are trying to call type on an element without a value', async () => { const {element} = setup('
') - return expect(userEvent.type(element, ':(', {delay: 1})).resolves.toBe( + await expect(userEvent.type(element, ':(', {delay: 1})).resolves.toBe( undefined, ) }) -test('typing on button should not alter its value', () => { +test('typing on button should not alter its value', async () => { const {element} = setup('
`) - const label = element.children[0] - const input = element.children[1] + const label = element.children[0] as HTMLLabelElement + const input = element.children[1] as HTMLInputElement - userEvent.upload(label, files) + await userEvent.upload(label, files) - expect(input.files[0]).toStrictEqual(files[0]) - expect(input.files.item(0)).toStrictEqual(files[0]) - expect(input.files[1]).toStrictEqual(files[1]) - expect(input.files.item(1)).toStrictEqual(files[1]) + expect(input.files?.[0]).toStrictEqual(files[0]) + expect(input.files?.item(0)).toStrictEqual(files[0]) + expect(input.files?.[1]).toStrictEqual(files[1]) + expect(input.files?.item(1)).toStrictEqual(files[1]) expect(input.files).toHaveLength(2) }) -test('should not upload when is disabled', () => { +test('should not upload when is disabled', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) - const {element} = setup('') + const {element} = setup('') - userEvent.upload(element, file) + await userEvent.upload(element, file) - expect(element.files[0]).toBeUndefined() - expect(element.files.item(0)).toBeNull() + expect(element.files?.[0]).toBeUndefined() + expect(element.files?.item(0)).toBeNull() expect(element.files).toHaveLength(0) }) -test('should call onChange/input bubbling up the event when a file is selected', () => { +test('should call onChange/input bubbling up the event when a file is selected', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) const {element: form} = setup(` @@ -136,7 +139,7 @@ test('should call onChange/input bubbling up the event when a file is selected', `) - const input = form.querySelector('input') + const input = form.querySelector('input') as HTMLInputElement const onChangeInput = jest.fn() const onChangeForm = jest.fn() @@ -154,7 +157,7 @@ test('should call onChange/input bubbling up the event when a file is selected', expect(onInputInput).toHaveBeenCalledTimes(0) expect(onInputForm).toHaveBeenCalledTimes(0) - userEvent.upload(input, file) + await userEvent.upload(input, file) expect(onChangeForm).toHaveBeenCalledTimes(1) expect(onChangeInput).toHaveBeenCalledTimes(1) @@ -170,28 +173,28 @@ test.each([ [false, 'video/*', 4], ])( 'should filter according to accept attribute applyAccept=%s, acceptAttribute=%s', - (applyAccept, acceptAttribute, expectedLength) => { + async (applyAccept, acceptAttribute, expectedLength) => { const files = [ new File(['hello'], 'hello.png', {type: 'image/png'}), new File(['there'], 'there.jpg', {type: 'audio/mp3'}), new File(['there'], 'there.csv', {type: 'text/csv'}), new File(['there'], 'there.jpg', {type: 'video/mp4'}), ] - const {element} = setup(` + const {element} = setup(` `) - userEvent.upload(element, files, undefined, {applyAccept}) + await userEvent.upload(element, files, undefined, {applyAccept}) expect(element.files).toHaveLength(expectedLength) }, ) -test('should not trigger input event when selected files are the same', () => { - const {element, eventWasFired, clearEventCalls} = setup( +test('should not trigger input event when selected files are the same', async () => { + const {element, eventWasFired, clearEventCalls} = setup( '', ) const files = [ @@ -199,62 +202,64 @@ test('should not trigger input event when selected files are the same', () => { new File(['there'], 'there.png', {type: 'image/png'}), ] - userEvent.upload(element, []) + await userEvent.upload(element, []) expect(eventWasFired('input')).toBe(false) expect(element.files).toHaveLength(0) - userEvent.upload(element, files) + await userEvent.upload(element, files) expect(eventWasFired('input')).toBe(true) expect(element.files).toHaveLength(2) clearEventCalls() - userEvent.upload(element, files) + await userEvent.upload(element, files) expect(eventWasFired('input')).toBe(false) expect(element.files).toHaveLength(2) - userEvent.upload(element, []) + await userEvent.upload(element, []) expect(eventWasFired('input')).toBe(true) expect(element.files).toHaveLength(0) }) -test('input.files implements iterable', () => { - const {element, getEvents} = setup(``) +test('input.files implements iterable', async () => { + const {element, getEvents} = setup( + ``, + ) const files = [ new File(['hello'], 'hello.png', {type: 'image/png'}), new File(['there'], 'there.png', {type: 'image/png'}), ] - userEvent.upload(element, files) - const eventTargetFiles = getEvents('input')[0].target.files + await userEvent.upload(element, files) + const eventTargetFiles = (getEvents('input')[0].target as HTMLInputElement) + .files expect(eventTargetFiles).toBe(element.files) expect(eventTargetFiles).not.toEqual(files) - expect(Array.from(eventTargetFiles)).toEqual(files) + expect(eventTargetFiles && Array.from(eventTargetFiles)).toEqual(files) }) -test('throw error if trying to use upload on an invalid element', () => { +test('throw error if trying to use upload on an invalid element', async () => { const {elements} = setup('
') - expect(() => - userEvent.upload(elements[0], "I'm only a div :("), - ).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.upload(elements[0], new File([], '')), + ).rejects.toThrowErrorMatchingInlineSnapshot( `The given DIV element does not accept file uploads`, ) - expect(() => - userEvent.upload(elements[1], "I'm a checkbox :("), - ).toThrowErrorMatchingInlineSnapshot( + await expect( + userEvent.upload(elements[1], new File([], '')), + ).rejects.toThrowErrorMatchingInlineSnapshot( `The associated INPUT element does not accept file uploads`, ) }) -test('apply init options', () => { +test('apply init options', async () => { const {element, getEvents} = setup('') - userEvent.upload(element, new File([], 'hello.png'), { - clickInit: {shiftKey: true}, + await userEvent.upload(element, new File([], 'hello.png'), { changeInit: {cancelable: true}, }) diff --git a/tests/utils/dataTransfer/Clipboard.ts b/tests/utils/dataTransfer/Clipboard.ts index 1fa7fbb6..9d71b21d 100644 --- a/tests/utils/dataTransfer/Clipboard.ts +++ b/tests/utils/dataTransfer/Clipboard.ts @@ -48,7 +48,7 @@ describe('read from and write to clipboard', () => { await expect(window.navigator.clipboard.readText()).resolves.toBe('') }) - test('detach clipboard', () => { + test('detach clipboard', async () => { expect(window.navigator.clipboard).not.toBe(undefined) detachClipboardStubFromView(window) expect(window.navigator.clipboard).toBe(undefined) diff --git a/tests/utils/dataTransfer/DataTransfer.ts b/tests/utils/dataTransfer/DataTransfer.ts index 0cec5fed..eb619ab0 100644 --- a/tests/utils/dataTransfer/DataTransfer.ts +++ b/tests/utils/dataTransfer/DataTransfer.ts @@ -1,7 +1,7 @@ import {createDataTransfer, getBlobFromDataTransferItem} from '#src/utils' describe('create DataTransfer', () => { - test('plain string', () => { + test('plain string', async () => { const dt = createDataTransfer() dt.setData('text/plain', 'foo') @@ -12,7 +12,7 @@ describe('create DataTransfer', () => { expect(callback).toBeCalledWith('foo') }) - test('multi format', () => { + test('multi format', async () => { const dt = createDataTransfer() dt.setData('text/plain', 'foo') dt.setData('text/html', 'bar') @@ -30,7 +30,7 @@ describe('create DataTransfer', () => { expect(dt.getData('text')).toBe('baz') }) - test('overwrite item', () => { + test('overwrite item', async () => { const dt = createDataTransfer() dt.setData('text/plain', 'foo') dt.setData('text/plain', 'bar') @@ -39,7 +39,7 @@ describe('create DataTransfer', () => { expect(dt.getData('text')).toBe('bar') }) - test('files operation', () => { + test('files operation', async () => { const f0 = new File(['bar'], 'bar0.txt', {type: 'text/plain'}) const f1 = new File(['bar'], 'bar1.txt', {type: 'text/plain'}) const dt = createDataTransfer([f0, f1]) @@ -49,7 +49,7 @@ describe('create DataTransfer', () => { expect(dt.files.length).toBe(2) }) - test('files item', () => { + test('files item', async () => { const f0 = new File(['bar'], 'bar0.txt', {type: 'text/plain'}) const dt = createDataTransfer() dt.setData('text/html', 'foo') @@ -65,7 +65,7 @@ describe('create DataTransfer', () => { expect(callback).not.toBeCalled() }) - test('clear data', () => { + test('clear data', async () => { const f0 = new File(['bar'], 'bar0.txt', {type: 'text/plain'}) const dt = createDataTransfer() dt.setData('text/html', 'foo') @@ -85,7 +85,7 @@ describe('create DataTransfer', () => { }) }) -test('get Blob from DataTransfer', () => { +test('get Blob from DataTransfer', async () => { const dt = createDataTransfer() dt.items.add('foo', 'text/plain') dt.items.add(new File(['bar'], 'bar.txt', {type: 'text/plain'})) diff --git a/tests/utils/edit/calculateNewValue.ts b/tests/utils/edit/calculateNewValue.ts index 0540f439..8d339c44 100644 --- a/tests/utils/edit/calculateNewValue.ts +++ b/tests/utils/edit/calculateNewValue.ts @@ -3,9 +3,9 @@ import {setup} from '#testHelpers/utils' // TODO: focus the maxlength tests on the tested aspects -test('honors maxlength', () => { +test('honors maxlength', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '123') + await userEvent.type(element, '123') // NOTE: no input event when typing "3" expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -38,9 +38,9 @@ test('honors maxlength', () => { `) }) -test('honors maxlength="" as if there was no maxlength', () => { +test('honors maxlength="" as if there was no maxlength', async () => { const {element, getEventSnapshot} = setup('') - userEvent.type(element, '123') + await userEvent.type(element, '123') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="123"] @@ -73,11 +73,11 @@ test('honors maxlength="" as if there was no maxlength', () => { `) }) -test('honors maxlength with existing text', () => { +test('honors maxlength with existing text', async () => { const {element, getEventSnapshot} = setup( '', ) - userEvent.type(element, '3') + await userEvent.type(element, '3') // NOTE: no input event when typing "3" expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -103,12 +103,12 @@ test('honors maxlength with existing text', () => { `) }) -test('honors maxlength on textarea', () => { +test('honors maxlength on textarea', async () => { const {element, getEventSnapshot} = setup( '', ) - userEvent.type(element, '3') + await userEvent.type(element, '3') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="12"] @@ -134,10 +134,10 @@ test('honors maxlength on textarea', () => { }) // https://github.com/testing-library/user-event/issues/418 -test('ignores maxlength on input[type=number]', () => { +test('ignores maxlength on input[type=number]', async () => { const {element} = setup(``) - userEvent.type(element, '3') + await userEvent.type(element, '3') expect(element).toHaveValue(123) }) diff --git a/tests/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts index 3d2885a3..070efd67 100644 --- a/tests/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -1,7 +1,7 @@ import {setup} from '#testHelpers/utils' import {isContentEditable} from '#src/utils' -test('report if element is contenteditable', () => { +test('report if element is contenteditable', async () => { const {elements} = setup( `
`, ) diff --git a/tests/utils/focus/blur.js b/tests/utils/focus/blur.ts similarity index 83% rename from tests/utils/focus/blur.js rename to tests/utils/focus/blur.ts index a92d8e60..ed5286f5 100644 --- a/tests/utils/focus/blur.js +++ b/tests/utils/focus/blur.ts @@ -1,7 +1,7 @@ import {blur, focus} from '#src/utils' import {setup} from '#testHelpers/utils' -test('blur a button', () => { +test('blur a button', async () => { const {element, getEventSnapshot, clearEventCalls} = setup(`'} - ${submit === 'input' && ''} - `, - ) - - element.querySelector('input')?.focus() +cases( + 'submit form on [Enter]', + async ({html, submit}) => { + const {element, getEvents} = setup(html) + ;(element.children[1] as HTMLInputElement).focus() await userEvent.keyboard('[Enter]') expect(getEvents('click')).toHaveLength(0) - expect(getEvents('submit')).toHaveLength(hasForm ? 1 : 0) + expect(getEvents('submit')).toHaveLength(submit ? 1 : 0) + }, + { + 'with ``': { + html: `
`, + submit: true, + }, + // TODO: submit with button without type attribute + // 'with ``) + + await userEvent.pointer({keys: '[MouseLeft]', target: element.children[0]}) + + expect(eventWasFired('submit')).toBe(true) + }) + + test('does not submit a form when clicking on a `, + ) + + await userEvent.pointer({keys: '[MouseLeft]', target: element.children[0]}) + + expect(eventWasFired('submit')).toBe(false) + }) +}) + +test('secondary mouse button fires `contextmenu` instead of `click`', async () => { + const {element, getEvents, clearEventCalls} = setup(`
+
+ `) + + await userEvent.pointer({keys: '[MouseLeft>]', target: element.children[1]}) + expect(element.children[1]).toHaveFocus() + + await userEvent.pointer({keys: '[MouseLeft>]', target: element.children[0]}) + expect(element).toHaveFocus() +}) + +test('mousedown handlers can prevent moving focus', async () => { + const {element} = setup(``) + element.addEventListener('mousedown', e => e.preventDefault()) + + await userEvent.pointer({keys: '[MouseLeft>]', target: element}) + + expect(element).not.toHaveFocus() + expect(element).toHaveProperty('selectionStart', 0) +}) + +test('single mousedown moves cursor to the last text', async () => { + const {element} = setup( + `
foo bar baz
`, + ) + + await userEvent.pointer({keys: '[MouseLeft>]', target: element}) + + expect(element).toHaveFocus() + expect(document.getSelection()).toHaveProperty( + 'focusNode', + element.firstChild, + ) + expect(document.getSelection()).toHaveProperty('focusOffset', 11) +}) + +test('double mousedown selects a word or a sequence of whitespace', async () => { + const {element} = setup(``) + + await userEvent.pointer({keys: '[MouseLeft][MouseLeft>]', target: element}) + + expect(element).toHaveProperty('selectionStart', 8) + expect(element).toHaveProperty('selectionEnd', 11) + + await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft>]', + target: element, + offset: 0, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 3) + + await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft]', + target: element, + offset: 11, + }) + + expect(element).toHaveProperty('selectionStart', 8) + expect(element).toHaveProperty('selectionEnd', 11) + + element.value = 'foo bar ' + + await userEvent.pointer({keys: '[MouseLeft][MouseLeft>]', target: element}) + + expect(element).toHaveProperty('selectionStart', 7) + expect(element).toHaveProperty('selectionEnd', 9) +}) + +test('triple mousedown selects whole line', async () => { + const {element} = setup(``) + + await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft][MouseLeft>]', + target: element, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 11) + + await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft][MouseLeft>]', + target: element, + offset: 0, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 11) + + await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft][MouseLeft>]', + target: element, + offset: 11, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 11) +}) + +test('mousemove with pressed button extends selection', async () => { + const {element} = setup(``) + + const pointerState = await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft>]', + target: element, + offset: 6, + }) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 7) + + await userEvent.pointer({offset: 2}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 2) + expect(element).toHaveProperty('selectionEnd', 7) + + await userEvent.pointer({offset: 10}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 10) + + await userEvent.pointer({}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 11) + + await userEvent.pointer({offset: 5}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 7) +}) + +test('selection is moved on non-input elements', async () => { + const {element} = setup( + `
foo bar baz
`, + ) + const span = element.querySelectorAll('span') + + const pointerState = await userEvent.pointer({ + keys: '[MouseLeft][MouseLeft>]', + target: element, + offset: 6, + }) + + expect(document.getSelection()?.toString()).toBe('bar') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[1].previousSibling, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[1].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) + + await userEvent.pointer({offset: 2}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('o bar') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 2, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[1].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) + + await userEvent.pointer({offset: 10}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('bar ba') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[1].previousSibling, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[2].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 2) + + await userEvent.pointer({}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('bar baz') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[1].previousSibling, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[2].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) +}) + +test('`node` overrides the text offset approximation', async () => { + const {element} = setup( + `
foo bar
baz
`, + ) + const div = element.firstChild as HTMLDivElement + const span = element.querySelectorAll('span') + + const pointerState = await userEvent.pointer({ + keys: '[MouseLeft>]', + target: element, + node: span[0].firstChild as Node, + offset: 1, + }) + await userEvent.pointer({node: div, offset: 3}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('oo bar') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + div, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) + + await userEvent.pointer({ + keys: '[MouseLeft]', + target: element, + node: span[0].firstChild as Node, + }) + expect(document.getSelection()?.toString()).toBe('') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 3, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) + + await userEvent.pointer({ + keys: '[MouseLeft]', + target: element, + node: span[0] as Node, + }) + expect(document.getSelection()?.toString()).toBe('') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0], + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[0], + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 1) +}) + +describe('focus control when clicking label', () => { + test('click event on label moves focus to control', async () => { + const { + elements: [input, label], + } = setup(`
@@ -85,7 +84,7 @@ instead of filing an issue on GitHub. We most sincerely thank [the people who make this project possible][contributors]. Contributions of any kind are welcome! πŸ’š -## LICENSE +## License [MIT](LICENSE) From e325c2e115e14ba0f1fc917a4be86058ca5e876f Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 11 Jan 2022 13:45:59 +0100 Subject: [PATCH 49/84] docs: update CONTRIBUTORS (#827) * Update @MichaelDeBoey as a contributor * Add @malipramod as a contributor * Add @wolfe111 as a contributor * Add @tyler2grass as a contributor * Add @micscopau as a contributor * Add @rbrady-hs as a contributor * Add @Dm1Korneev as a contributor * Add @kumachan-mis as a contributor * Add @themadtitanmathos as a contributor * Add @bamthomas as a contributor * Add @antfu as a contributor * Add @mohetti as a contributor * Add @dannyharding10 as a contributor * Add @lucaslcode as a contributor * Add @MatanBobi as a contributor * Update @timdeschryver as a contributor * Update @nickmccurdy as a contributor * Add @kentcdodds as a contributor * Update @ph-fritsche as a contributor --- .all-contributorsrc | 155 ++++++++++++++++++++++++++++++++++++++++++-- CONTRIBUTORS.md | 27 ++++++-- 2 files changed, 174 insertions(+), 8 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 737f6d67..a490dc36 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -50,7 +50,8 @@ "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", "profile": "https://michaeldeboey.be", "contributions": [ - "doc" + "doc", + "ideas" ] }, { @@ -423,7 +424,9 @@ "question", "code", "test", - "doc" + "doc", + "infra", + "ideas" ] }, { @@ -432,7 +435,8 @@ "avatar_url": "https://avatars1.githubusercontent.com/u/28659384?v=4", "profile": "http://timdeschryver.dev", "contributions": [ - "test" + "test", + "doc" ] }, { @@ -568,7 +572,11 @@ "profile": "https://github.com/ph-fritsche", "contributions": [ "code", - "test" + "test", + "bug", + "ideas", + "infra", + "maintenance" ] }, { @@ -966,6 +974,145 @@ "contributions": [ "code" ] + }, + { + "login": "malipramod", + "name": "Pramod Mali", + "avatar_url": "https://avatars.githubusercontent.com/u/13375870?v=4", + "profile": "https://pramodmali.tech/", + "contributions": [ + "ideas" + ] + }, + { + "login": "wolfe111", + "name": "wolfe111", + "avatar_url": "https://avatars.githubusercontent.com/u/15180314?v=4", + "profile": "https://github.com/wolfe111", + "contributions": [ + "bug" + ] + }, + { + "login": "tyler2grass", + "name": "Tyler Grass", + "avatar_url": "https://avatars.githubusercontent.com/u/88393125?v=4", + "profile": "https://github.com/tyler2grass", + "contributions": [ + "bug" + ] + }, + { + "login": "micscopau", + "name": "Michael Pauly", + "avatar_url": "https://avatars.githubusercontent.com/u/7364791?v=4", + "profile": "https://www.linkedin.com/in/michael-s-pauly/", + "contributions": [ + "bug" + ] + }, + { + "login": "rbrady-hs", + "name": "rbrady-hs", + "avatar_url": "https://avatars.githubusercontent.com/u/83345629?v=4", + "profile": "https://github.com/rbrady-hs", + "contributions": [ + "ideas" + ] + }, + { + "login": "Dm1Korneev", + "name": "Dmitriy Кorneev", + "avatar_url": "https://avatars.githubusercontent.com/u/7955306?v=4", + "profile": "https://github.com/Dm1Korneev", + "contributions": [ + "bug" + ] + }, + { + "login": "kumachan-mis", + "name": "Kumachan", + "avatar_url": "https://avatars.githubusercontent.com/u/29433058?v=4", + "profile": "https://github.com/kumachan-mis", + "contributions": [ + "bug" + ] + }, + { + "login": "themadtitanmathos", + "name": "Matthew Lloyd Williamson", + "avatar_url": "https://avatars.githubusercontent.com/u/54560914?v=4", + "profile": "https://github.com/themadtitanmathos", + "contributions": [ + "ideas" + ] + }, + { + "login": "bamthomas", + "name": "Bruno Thomas", + "avatar_url": "https://avatars.githubusercontent.com/u/551723?v=4", + "profile": "https://github.com/bamthomas", + "contributions": [ + "bug" + ] + }, + { + "login": "antfu", + "name": "Anthony Fu", + "avatar_url": "https://avatars.githubusercontent.com/u/11247099?v=4", + "profile": "https://antfu.me/", + "contributions": [ + "bug" + ] + }, + { + "login": "mohetti", + "name": "momokolo", + "avatar_url": "https://avatars.githubusercontent.com/u/73931283?v=4", + "profile": "https://github.com/mohetti", + "contributions": [ + "bug" + ] + }, + { + "login": "dannyharding10", + "name": "Danny", + "avatar_url": "https://avatars.githubusercontent.com/u/11875246?v=4", + "profile": "https://github.com/dannyharding10", + "contributions": [ + "bug" + ] + }, + { + "login": "lucaslcode", + "name": "Lucas Levin", + "avatar_url": "https://avatars.githubusercontent.com/u/32044095?v=4", + "profile": "https://lucas-levin.com/", + "contributions": [ + "bug" + ] + }, + { + "login": "MatanBobi", + "name": "Matan Borenkraout", + "avatar_url": "https://avatars.githubusercontent.com/u/12711091?v=4", + "profile": "https://matan.io/", + "contributions": [ + "doc" + ] + }, + { + "login": "kentcdodds", + "name": "Kent C. Dodds", + "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=4", + "profile": "https://kentcdodds.com/", + "contributions": [ + "code", + "infra", + "maintenance", + "review", + "test" + ] } ], "commitConvention": "none", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2a8aa3ec..e43c8e82 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -10,7 +10,7 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Giorgio Polvara

πŸ› πŸ’» πŸ“– πŸ€” πŸš‡ πŸ‘€ ⚠️
Weyert de Boer

πŸ’» ⚠️
Tim Whitbeck

πŸ› πŸ’» -
MichaΓ«l De Boey

πŸ“– +
MichaΓ«l De Boey

πŸ“– πŸ€”
Michael Lasky

πŸ’» πŸ“– πŸ€”
Ahmad Esmaeilzadeh

πŸ“–
Caleb Eby

πŸ’» πŸ› πŸ‘€ @@ -58,10 +58,10 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Vijay Kumar Otti

πŸ›
Tom Picton

πŸ› πŸ’» ⚠️
Hung Viet Nguyen

πŸ› -
Nick McCurdy

πŸ“† πŸ’¬ πŸ’» ⚠️ πŸ“– +
Nick McCurdy

πŸ“† πŸ’¬ πŸ’» ⚠️ πŸ“– πŸš‡ πŸ€” -
Tim Deschryver

⚠️ +
Tim Deschryver

⚠️ πŸ“–
Ben Dyer

πŸ’» ⚠️
Dan Kirkham

πŸ’»
Johannesklint

πŸ“– @@ -79,7 +79,7 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Maxwell Newlands

πŸ’» ⚠️ -
ph-fritsche

πŸ’» ⚠️ +
ph-fritsche

πŸ’» ⚠️ πŸ› πŸ€” πŸš‡ 🚧
Rey Wright

πŸ› πŸ’»
Niklas Mischkulnig

πŸ’» ⚠️
Pascal Duez

πŸ’» @@ -134,6 +134,25 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Patrick LizoΕ„

πŸ’» +
Pramod Mali

πŸ€” +
wolfe111

πŸ› +
Tyler Grass

πŸ› +
Michael Pauly

πŸ› +
rbrady-hs

πŸ€” +
Dmitriy Кorneev

πŸ› + + +
Kumachan

πŸ› +
Matthew Lloyd Williamson

πŸ€” +
Bruno Thomas

πŸ› +
Anthony Fu

πŸ› +
momokolo

πŸ› +
Danny

πŸ› +
Lucas Levin

πŸ› + + +
Matan Borenkraout

πŸ“– +
Kent C. Dodds

πŸ’» πŸš‡ 🚧 πŸ‘€ ⚠️ From d35ca6950d0e7690dee6ae0819533ab35377067f Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Wed, 12 Jan 2022 09:56:15 +0100 Subject: [PATCH 50/84] docs: update CONTRIBUTORS (#829) --- .all-contributorsrc | 11 +++++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index a490dc36..a002c7d4 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1113,6 +1113,17 @@ "review", "test" ] + }, + { + "login": "Dennis273", + "name": "Dennis273", + "avatar_url": "https://avatars.githubusercontent.com/u/19815164?v=4", + "profile": "https://github.com/Dennis273", + "contributions": [ + "bug", + "code", + "test" + ] } ], "commitConvention": "none", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e43c8e82..e3fc0ede 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -153,6 +153,7 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Matan Borenkraout

πŸ“–
Kent C. Dodds

πŸ’» πŸš‡ 🚧 πŸ‘€ ⚠️ +
Dennis273

πŸ› πŸ’» ⚠️ From d64167ca40506e381aff53c985b19e6d2a8a156d Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Mon, 17 Jan 2022 00:21:28 +0100 Subject: [PATCH 51/84] fix(pointer): blur `activeElement` on click outside of focusable (#834) --- src/pointer/pointerPress.ts | 3 +-- src/utility/upload.ts | 4 ++-- src/utils/focus/focus.ts | 20 ++++++++++++++------ src/utils/misc/findClosest.ts | 6 +++--- tests/pointer/select.ts | 13 +++++++++++++ tests/utility/upload.ts | 2 ++ 6 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index 087c1b87..004e767d 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -3,7 +3,6 @@ import { ApiLevel, assertPointerEvents, - findClosest, firePointerEvent, focus, isDisabled, @@ -278,7 +277,7 @@ function mousedownDefaultBehavior({ // The closest focusable element is focused when a `mousedown` would have been fired. // Even if there was no `mousedown` because the element was disabled. // A `mousedown` that preventsDefault cancels this though. - focus(findClosest(target, isFocusable) ?? target.ownerDocument.body) + focus(target) // TODO: What happens if a focus event handler interfers? diff --git a/src/utility/upload.ts b/src/utility/upload.ts index 408eda91..604e7b5f 100644 --- a/src/utility/upload.ts +++ b/src/utility/upload.ts @@ -38,9 +38,9 @@ export async function upload( .slice(0, input.multiple ? undefined : 1) // blur fires when the file selector pops up - blur(element) + blur(input) // focus fires when they make their selection - focus(element) + focus(input) // do not fire an input event if the file selection does not change if ( diff --git a/src/utils/focus/focus.ts b/src/utils/focus/focus.ts index e41a6708..d55961c6 100644 --- a/src/utils/focus/focus.ts +++ b/src/utils/focus/focus.ts @@ -1,17 +1,25 @@ import {eventWrapper} from '../misc/eventWrapper' +import {findClosest} from '../misc/findClosest' import {getActiveElement} from './getActiveElement' import {isFocusable} from './isFocusable' import {updateSelectionOnFocus} from './selection' +/** + * Focus closest focusable element. + */ function focus(element: Element) { - if (!isFocusable(element)) return + const target = findClosest(element, isFocusable) - const isAlreadyActive = getActiveElement(element.ownerDocument) === element - if (isAlreadyActive) return + const activeElement = getActiveElement(element.ownerDocument) + if ((target ?? element.ownerDocument.body) === activeElement) { + return + } else if (target) { + eventWrapper(() => target.focus()) + } else { + eventWrapper(() => (activeElement as HTMLElement | null)?.blur()) + } - eventWrapper(() => element.focus()) - - updateSelectionOnFocus(element) + updateSelectionOnFocus(target ?? element.ownerDocument.body) } export {focus} diff --git a/src/utils/misc/findClosest.ts b/src/utils/misc/findClosest.ts index db05e44b..9a5e2de6 100644 --- a/src/utils/misc/findClosest.ts +++ b/src/utils/misc/findClosest.ts @@ -1,7 +1,7 @@ -export function findClosest( +export function findClosest( element: Element, - callback: (e: Element) => boolean, -) { + callback: (e: Element) => e is T, +): T | undefined { let el: Element | null = element do { if (callback(el)) { diff --git a/tests/pointer/select.ts b/tests/pointer/select.ts index 30df93b6..284ebfec 100644 --- a/tests/pointer/select.ts +++ b/tests/pointer/select.ts @@ -30,6 +30,19 @@ test('move focus to closest focusable element', async () => { expect(element).toHaveFocus() }) +test('blur when outside of focusable context', async () => { + const { + elements: [focusable, notFocusable], + } = setup(` +
+
+ `) + focusable.focus() + + await userEvent.pointer({keys: '[MouseLeft>]', target: notFocusable}) + expect(document.body).toHaveFocus() +}) + test('mousedown handlers can prevent moving focus', async () => { const {element} = setup(``) element.addEventListener('mousedown', e => e.preventDefault()) diff --git a/tests/utility/upload.ts b/tests/utility/upload.ts index 996643e1..21b00f93 100644 --- a/tests/utility/upload.ts +++ b/tests/utility/upload.ts @@ -65,6 +65,8 @@ test('relay click/upload on label to file input', async () => { label[for="element"] - click: primary input#element[value=""] - click: primary input#element[value=""] - focusin + input#element[value=""] - focusout + input#element[value=""] - focusin input#element[value="C:\\\\fakepath\\\\hello.png"] - input input#element[value="C:\\\\fakepath\\\\hello.png"] - change `) From a5ca2e47f30ea71b72a443c227ea125beb4e84b7 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 17 Jan 2022 00:52:26 +0100 Subject: [PATCH 52/84] docs: add piecyk as a contributor for bug (#837) * docs: update CONTRIBUTORS.md * docs: update .all-contributorsrc Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index a002c7d4..8b30e3cd 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1124,6 +1124,15 @@ "code", "test" ] + }, + { + "login": "piecyk", + "name": "Damian Pieczynski", + "avatar_url": "https://avatars.githubusercontent.com/u/82964?v=4", + "profile": "https://twitter.com/piecu", + "contributions": [ + "bug" + ] } ], "commitConvention": "none", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e3fc0ede..36b7f3fd 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -154,6 +154,7 @@ Thanks goes to these wonderful people ([emoji key][emojis]):
Matan Borenkraout

πŸ“–
Kent C. Dodds

πŸ’» πŸš‡ 🚧 πŸ‘€ ⚠️
Dennis273

πŸ› πŸ’» ⚠️ +
Damian Pieczynski

πŸ› From 4720ac2c4a518b8a461d2f5f8f10d0e287ab4982 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 3 Feb 2022 15:57:03 +0100 Subject: [PATCH 53/84] refactor: dispatch all UI events per one internal API (#838) * refactor: centralize calls to createEvent * refactor: centralize dispatching ui events * refactor: apply event props in dispatcher * refactor: inline paste implementation --- src/clipboard/copy.ts | 3 +- src/clipboard/cut.ts | 5 +- src/clipboard/paste.ts | 17 +-- src/document/index.ts | 5 +- src/event/createEvent.ts | 98 ++++++++++++++ src/event/dom-events.d.ts | 9 ++ src/event/eventTypes.ts | 20 +++ src/event/index.ts | 30 +++++ src/event/types.ts | 14 ++ src/event/wrapEvent.ts | 5 + src/keyboard/index.ts | 4 +- src/keyboard/keyboardAction.ts | 18 ++- src/keyboard/plugins/character.ts | 38 +++--- src/keyboard/plugins/control.ts | 4 +- src/keyboard/plugins/functional.ts | 23 ++-- src/keyboard/plugins/modifiers.ts | 21 +-- .../shared/fireChangeForInputTimeIfValid.ts | 8 +- src/pointer/firePointerEvents.ts | 48 +++++++ src/pointer/pointerMove.ts | 12 +- src/pointer/pointerPress.ts | 35 ++--- src/pointer/types.ts | 3 +- src/setup/index.ts | 2 + src/setup/setup.ts | 47 ++++--- src/utility/clear.ts | 4 +- src/utility/selectOptions.ts | 61 ++++----- src/utility/upload.ts | 5 +- src/utils/edit/editInputElement.ts | 12 +- src/utils/edit/prepareInput.ts | 8 +- src/utils/index.ts | 1 - src/utils/keyboard/getKeyEventProps.ts | 6 +- src/utils/pointer/dom-events.d.ts | 3 - src/utils/pointer/firePointerEvents.ts | 126 ------------------ tests/__mocks__/@testing-library/dom.js | 50 ------- tests/_helpers/trackProps.ts | 59 ++++++++ tests/_helpers/utils.ts | 32 ++--- tests/_setup-env.js | 1 + tests/utils/edit/prepareInput.ts | 7 +- 37 files changed, 480 insertions(+), 364 deletions(-) create mode 100644 src/event/createEvent.ts create mode 100644 src/event/dom-events.d.ts create mode 100644 src/event/eventTypes.ts create mode 100644 src/event/index.ts create mode 100644 src/event/types.ts create mode 100644 src/event/wrapEvent.ts create mode 100644 src/pointer/firePointerEvents.ts delete mode 100644 src/utils/pointer/dom-events.d.ts delete mode 100644 src/utils/pointer/firePointerEvents.ts delete mode 100644 tests/__mocks__/@testing-library/dom.js create mode 100644 tests/_helpers/trackProps.ts diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index b882bbed..3f9ad96b 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,4 +1,3 @@ -import {fireEvent} from '@testing-library/dom' import {Config, Instance} from '../setup' import {copySelection, writeDataTransferToClipboard} from '../utils' @@ -12,7 +11,7 @@ export async function copy(this: Instance) { return } - fireEvent.copy(target, { + this.dispatchUIEvent(target, 'copy', { clipboardData, }) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index 229e6835..d1045b66 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,4 +1,3 @@ -import {fireEvent} from '@testing-library/dom' import {Config, Instance} from '../setup' import { copySelection, @@ -17,12 +16,12 @@ export async function cut(this: Instance) { return } - fireEvent.cut(target, { + this.dispatchUIEvent(target, 'cut', { clipboardData, }) if (isEditable(target)) { - prepareInput('', target, 'deleteByCut')?.commit() + prepareInput(this[Config], '', target, 'deleteByCut')?.commit() } if (this[Config].writeToClipboard) { diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 3288a6a4..694e96d1 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,4 +1,3 @@ -import {fireEvent} from '@testing-library/dom' import {Config, Instance} from '../setup' import { createDataTransfer, @@ -15,7 +14,7 @@ export async function paste( const doc = this[Config].document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body - const data: DataTransfer = + const dataTransfer: DataTransfer = (typeof clipboardData === 'string' ? getClipboardDataFromString(clipboardData) : clipboardData) ?? @@ -25,21 +24,17 @@ export async function paste( ) })) - return pasteImpl(target, data) -} - -function pasteImpl(target: Element, clipboardData: DataTransfer) { - fireEvent.paste(target, { - clipboardData, + this.dispatchUIEvent(target, 'paste', { + clipboardData: dataTransfer, }) if (isEditable(target)) { - const data = clipboardData + const textData = dataTransfer .getData('text') .substr(0, getSpaceUntilMaxLength(target)) - if (data) { - prepareInput(data, target, 'insertFromPaste')?.commit() + if (textData) { + prepareInput(this[Config], textData, target, 'insertFromPaste')?.commit() } } } diff --git a/src/document/index.ts b/src/document/index.ts index ec0dda9b..7cb2e3d5 100644 --- a/src/document/index.ts +++ b/src/document/index.ts @@ -1,4 +1,5 @@ -import {fireEvent} from '@testing-library/dom' +import {dispatchUIEvent} from '../event' +import {Config} from '../setup' import {prepareSelectionInterceptor} from './selection' import { getInitialValue, @@ -45,7 +46,7 @@ export function prepareDocument(document: Document) { const el = e.target as HTMLInputElement const initialValue = getInitialValue(el) if (typeof initialValue === 'string' && el.value !== initialValue) { - fireEvent.change(el) + dispatchUIEvent({} as Config, el, 'change') } }, { diff --git a/src/event/createEvent.ts b/src/event/createEvent.ts new file mode 100644 index 00000000..d4b27789 --- /dev/null +++ b/src/event/createEvent.ts @@ -0,0 +1,98 @@ +import {createEvent as createEventBase} from '@testing-library/dom' +import {eventMap} from '@testing-library/dom/dist/event-map.js' +import {isMouseEvent} from './eventTypes' +import {EventType, PointerCoords} from './types' + +export type EventTypeInit = SpecificEventInit< + FixedDocumentEventMap[K] +> + +interface FixedDocumentEventMap extends DocumentEventMap { + input: InputEvent +} + +type SpecificEventInit = E extends InputEvent + ? InputEventInit + : E extends ClipboardEvent + ? ClipboardEventInit + : E extends KeyboardEvent + ? KeyboardEventInit + : E extends PointerEvent + ? PointerEventInit + : E extends MouseEvent + ? MouseEventInit + : E extends UIEvent + ? UIEventInit + : EventInit + +export function createEvent( + type: K, + target: Element, + init?: EventTypeInit, +) { + const eventKey = Object.keys(eventMap).find( + k => k.toLowerCase() === type, + ) as keyof typeof createEventBase + + const event = createEventBase[eventKey](target, init) as DocumentEventMap[K] + + // Can not use instanceof, as MouseEvent might be polyfilled. + if (isMouseEvent(type) && init) { + // see https://github.com/testing-library/react-testing-library/issues/268 + assignPositionInit(event as MouseEvent, init) + assignPointerInit(event as PointerEvent, init) + } + + return event +} + +function assignProps( + obj: MouseEvent | PointerEvent, + props: MouseEventInit & PointerEventInit & PointerCoords, +) { + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(obj, key, {get: () => value}) + } +} + +function assignPositionInit( + obj: MouseEvent | PointerEvent, + { + x, + y, + clientX, + clientY, + offsetX, + offsetY, + pageX, + pageY, + screenX, + screenY, + }: PointerCoords & MouseEventInit, +) { + assignProps(obj, { + /* istanbul ignore start */ + x: x ?? clientX ?? 0, + y: y ?? clientY ?? 0, + clientX: x ?? clientX ?? 0, + clientY: y ?? clientY ?? 0, + offsetX: offsetX ?? 0, + offsetY: offsetY ?? 0, + pageX: pageX ?? 0, + pageY: pageY ?? 0, + screenX: screenX ?? 0, + screenY: screenY ?? 0, + /* istanbul ignore end */ + }) +} + +function assignPointerInit( + obj: MouseEvent | PointerEvent, + {isPrimary, pointerId, pointerType}: PointerEventInit, +) { + assignProps(obj, { + isPrimary, + pointerId, + pointerType, + }) +} diff --git a/src/event/dom-events.d.ts b/src/event/dom-events.d.ts new file mode 100644 index 00000000..18d4fbd1 --- /dev/null +++ b/src/event/dom-events.d.ts @@ -0,0 +1,9 @@ +declare module '@testing-library/dom/dist/event-map.js' { + import {EventType} from '@testing-library/dom' + export const eventMap: { + [k in EventType]: { + EventType: string + defaultInit: EventInit + } + } +} diff --git a/src/event/eventTypes.ts b/src/event/eventTypes.ts new file mode 100644 index 00000000..6c02d297 --- /dev/null +++ b/src/event/eventTypes.ts @@ -0,0 +1,20 @@ +import {eventMap} from '@testing-library/dom/dist/event-map.js' + +const eventKeys = Object.fromEntries( + Object.keys(eventMap).map(k => [k.toLowerCase(), k]), +) as { + [k in keyof DocumentEventMap]: keyof typeof eventMap +} + +function getEventClass(type: keyof DocumentEventMap) { + return eventMap[eventKeys[type]].EventType +} + +const mouseEvents = ['MouseEvent', 'PointerEvent'] +export function isMouseEvent(type: keyof DocumentEventMap) { + return mouseEvents.includes(getEventClass(type)) +} + +export function isKeyboardEvent(type: keyof DocumentEventMap) { + return getEventClass(type) === 'KeyboardEvent' +} diff --git a/src/event/index.ts b/src/event/index.ts new file mode 100644 index 00000000..b475a1d5 --- /dev/null +++ b/src/event/index.ts @@ -0,0 +1,30 @@ +import {Config} from '../setup' +import {getUIEventModifiers} from '../utils' +import {createEvent, EventTypeInit} from './createEvent' +import {isKeyboardEvent, isMouseEvent} from './eventTypes' +import {EventType, PointerCoords} from './types' +import {wrapEvent} from './wrapEvent' + +export type {EventType, PointerCoords} + +export function dispatchUIEvent( + config: Config, + target: Element, + type: K, + init?: EventTypeInit, +) { + if (isMouseEvent(type) || isKeyboardEvent(type)) { + init = { + ...init, + ...getUIEventModifiers(config.keyboardState), + } as EventTypeInit + } + + const event = createEvent(type, target, init) + + return wrapEvent(() => target.dispatchEvent(event), target) +} + +export function bindDispatchUIEvent(config: Config) { + return dispatchUIEvent.bind(undefined, config) +} diff --git a/src/event/types.ts b/src/event/types.ts new file mode 100644 index 00000000..8d02a8c9 --- /dev/null +++ b/src/event/types.ts @@ -0,0 +1,14 @@ +export type EventType = keyof DocumentEventMap + +export interface PointerCoords { + x?: number + y?: number + clientX?: number + clientY?: number + offsetX?: number + offsetY?: number + pageX?: number + pageY?: number + screenX?: number + screenY?: number +} diff --git a/src/event/wrapEvent.ts b/src/event/wrapEvent.ts new file mode 100644 index 00000000..3f436ccf --- /dev/null +++ b/src/event/wrapEvent.ts @@ -0,0 +1,5 @@ +import {getConfig} from '@testing-library/dom' + +export function wrapEvent(cb: () => R, _element: Element) { + return getConfig().eventWrapper(cb) as unknown as R +} diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 167c2af9..7a37a664 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -7,9 +7,7 @@ export {releaseAllKeys} export type {keyboardKey, keyboardState} export async function keyboard(this: Instance, text: string): Promise { - const {keyboardMap} = this[Config] - - const actions: KeyboardAction[] = parseKeyDef(keyboardMap, text) + const actions: KeyboardAction[] = parseKeyDef(this[Config].keyboardMap, text) return keyboardAction(this[Config], actions) } diff --git a/src/keyboard/keyboardAction.ts b/src/keyboard/keyboardAction.ts index a6476607..b29d8dab 100644 --- a/src/keyboard/keyboardAction.ts +++ b/src/keyboard/keyboardAction.ts @@ -1,4 +1,4 @@ -import {fireEvent} from '@testing-library/dom' +import {dispatchUIEvent} from '../event' import {Config} from '../setup' import {getActiveElement, getKeyEventProps, wait} from '../utils' import {behaviorPlugin, keyboardKey} from './types' @@ -85,9 +85,11 @@ async function keydown( applyPlugins(plugins.preKeydownBehavior, keyDef, element, config) - const unpreventedDefault = fireEvent.keyDown( + const unpreventedDefault = dispatchUIEvent( + config, element, - getKeyEventProps(keyDef, config.keyboardState), + 'keydown', + getKeyEventProps(keyDef), ) config.keyboardState.pressed.push({keyDef, unpreventedDefault}) @@ -107,8 +109,8 @@ async function keypress( ) { const element = getCurrentElement() - const unpreventedDefault = fireEvent.keyPress(element, { - ...getKeyEventProps(keyDef, config.keyboardState), + const unpreventedDefault = dispatchUIEvent(config, element, 'keypress', { + ...getKeyEventProps(keyDef), charCode: keyDef.key === 'Enter' ? 13 : String(keyDef.key).charCodeAt(0), }) @@ -127,9 +129,11 @@ async function keyup( applyPlugins(plugins.preKeyupBehavior, keyDef, element, config) - const unpreventedDefault = fireEvent.keyUp( + const unpreventedDefault = dispatchUIEvent( + config, element, - getKeyEventProps(keyDef, config.keyboardState), + 'keyup', + getKeyEventProps(keyDef), ) if (unprevented && unpreventedDefault) { diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index 1a7234b2..8add133b 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -2,7 +2,6 @@ * This file should cover the behavior for keys that produce character input */ -import {fireEvent} from '@testing-library/dom' import {fireChangeForInputTimeIfValid} from '../shared' import {behaviorPlugin} from '../types' import { @@ -19,17 +18,18 @@ import { isValidInputTimeValue, prepareInput, } from '../../utils' -import {UISelectionRange} from '../../document' +import {setUIValue, UISelectionRange} from '../../document' +import {dispatchUIEvent} from '../../event' export const keypressBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'time', readOnly: false}), - handle: (keyDef, element, {keyboardState}) => { + handle: (keyDef, element, config) => { let newEntry = keyDef.key as string - const textToBeTyped = (keyboardState.carryValue ?? '') + newEntry + const textToBeTyped = (config.keyboardState.carryValue ?? '') + newEntry const timeNewEntry = buildTimeValue(textToBeTyped) if ( isValidInputTimeValue( @@ -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) { - editInputElement(element as HTMLInputElement, { + editInputElement(config, element as HTMLInputElement, { newValue, newSelection: { node: element, @@ -64,22 +64,23 @@ export const keypressBehavior: behaviorPlugin[] = [ } fireChangeForInputTimeIfValid( + config, element as HTMLInputElement & {type: 'time'}, prevValue, timeNewEntry, ) - keyboardState.carryValue = textToBeTyped + config.keyboardState.carryValue = textToBeTyped }, }, { matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'date', readOnly: false}), - handle: (keyDef, element, {keyboardState}) => { + handle: (keyDef, element, config) => { let newEntry = keyDef.key as string - const textToBeTyped = (keyboardState.carryValue ?? '') + newEntry + const textToBeTyped = (config.keyboardState.carryValue ?? '') + newEntry const isValidToBeTyped = isValidDateValue( element as HTMLInputElement & {type: 'date'}, textToBeTyped, @@ -98,7 +99,7 @@ export const keypressBehavior: behaviorPlugin[] = [ // this check was provided by fireInputEventIfNeeded // TODO: verify if it is even needed by this handler if (prevValue !== newValue) { - editInputElement(element as HTMLInputElement, { + editInputElement(config, element as HTMLInputElement, { newValue, newSelection: { node: element, @@ -112,24 +113,24 @@ export const keypressBehavior: behaviorPlugin[] = [ } if (isValidToBeTyped) { - fireEvent.change(element, { - target: {value: textToBeTyped}, - }) + setUIValue(element as HTMLInputElement, textToBeTyped) + dispatchUIEvent(config, element, 'change') } - keyboardState.carryValue = textToBeTyped + config.keyboardState.carryValue = textToBeTyped }, }, { matches: (keyDef, element) => keyDef.key?.length === 1 && isElementType(element, 'input', {type: 'number', readOnly: false}), - handle: (keyDef, element) => { + handle: (keyDef, element, config) => { if (!/[\d.\-e]/.test(keyDef.key as string)) { return } const {getNewValue, commit} = prepareInput( + config, keyDef.key as string, element, ) as NonNullable> @@ -157,8 +158,8 @@ export const keypressBehavior: behaviorPlugin[] = [ isElementType(element, 'textarea', {readOnly: false}) || isContentEditable(element)) && getSpaceUntilMaxLength(element) !== 0, - handle: (keyDef, element) => { - prepareInput(keyDef.key as string, element)?.commit() + handle: (keyDef, element, config) => { + prepareInput(config, keyDef.key as string, element)?.commit() }, }, { @@ -167,11 +168,12 @@ export const keypressBehavior: behaviorPlugin[] = [ (isElementType(element, 'textarea', {readOnly: false}) || isContentEditable(element)) && getSpaceUntilMaxLength(element) !== 0, - handle: (keyDef, element, {keyboardState}) => { + handle: (keyDef, element, config) => { prepareInput( + config, '\n', element, - isContentEditable(element) && !keyboardState.modifiers.Shift + isContentEditable(element) && !config.keyboardState.modifiers.Shift ? 'insertParagraph' : 'insertLineBreak', )?.commit() diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts index 1d97b163..60b67ad5 100644 --- a/src/keyboard/plugins/control.ts +++ b/src/keyboard/plugins/control.ts @@ -46,8 +46,8 @@ export const keydownBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key === 'Delete' && isEditable(element), - handle: (keDef, element) => { - prepareInput('', element, 'deleteContentForward')?.commit() + handle: (keDef, element, config) => { + prepareInput(config, '', element, 'deleteContentForward')?.commit() }, }, ] diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts index dbba404e..415ce4f8 100644 --- a/src/keyboard/plugins/functional.ts +++ b/src/keyboard/plugins/functional.ts @@ -3,13 +3,12 @@ * https://w3c.github.io/uievents-code/#key-alphanumeric-functional */ -import {fireEvent} from '@testing-library/dom' import {setUISelection} from '../../document' +import {dispatchUIEvent} from '../../event' import { blur, focus, getTabDestination, - getUIEventModifiers, hasFormSubmit, isClickableInput, isEditable, @@ -22,8 +21,8 @@ export const keydownBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key === 'Backspace' && isEditable(element), - handle: (keyDef, element) => { - prepareInput('', element, 'deleteContentBackward')?.commit() + handle: (keyDef, element, config) => { + prepareInput(config, '', element, 'deleteContentBackward')?.commit() }, }, { @@ -51,11 +50,11 @@ export const keypressBehavior: behaviorPlugin[] = [ keyDef.key === 'Enter' && isElementType(element, 'input') && ['checkbox', 'radio'].includes(element.type), - handle: (keyDef, element) => { + handle: (keyDef, element, config) => { const form = (element as HTMLInputElement).form if (hasFormSubmit(form)) { - fireEvent.submit(form) + dispatchUIEvent(config, form, 'submit') } }, }, @@ -65,21 +64,21 @@ export const keypressBehavior: behaviorPlugin[] = [ (isClickableInput(element) || // Links with href defined should handle Enter the same as a click (isElementType(element, 'a') && Boolean(element.href))), - handle: (keyDef, element, {keyboardState}) => { - fireEvent.click(element, getUIEventModifiers(keyboardState)) + handle: (keyDef, element, config) => { + dispatchUIEvent(config, element, 'click') }, }, { matches: (keyDef, element) => keyDef.key === 'Enter' && isElementType(element, 'input'), - handle: (keyDef, element) => { + handle: (keyDef, element, config) => { const form = (element as HTMLInputElement).form if ( form && (form.querySelectorAll('input').length === 1 || hasFormSubmit(form)) ) { - fireEvent.submit(form) + dispatchUIEvent(config, form, 'submit') } }, }, @@ -89,8 +88,8 @@ export const keyupBehavior: behaviorPlugin[] = [ { matches: (keyDef, element) => keyDef.key === ' ' && isClickableInput(element), - handle: (keyDef, element, {keyboardState}) => { - fireEvent.click(element, getUIEventModifiers(keyboardState)) + handle: (keyDef, element, config) => { + dispatchUIEvent(config, element, 'click') }, }, ] diff --git a/src/keyboard/plugins/modifiers.ts b/src/keyboard/plugins/modifiers.ts index b470a2a7..2c948b3f 100644 --- a/src/keyboard/plugins/modifiers.ts +++ b/src/keyboard/plugins/modifiers.ts @@ -3,7 +3,7 @@ * https://www.w3.org/TR/uievents-key/#keys-modifier */ -import {fireEvent} from '@testing-library/dom' +import {dispatchUIEvent} from '../../event' import {getKeyEventProps} from '../../utils' import {behaviorPlugin} from '../types' @@ -31,16 +31,21 @@ type ModififierLockKey = typeof modifierLocks[number] export const preKeydownBehavior: behaviorPlugin[] = [ { matches: keyDef => modifierKeys.includes(keyDef.key as ModififierKey), - handle: (keyDef, element, {keyboardMap, keyboardState}) => { - keyboardState.modifiers[keyDef.key as ModififierKey] = true + handle: (keyDef, element, config) => { + config.keyboardState.modifiers[keyDef.key as ModififierKey] = true // AltGraph produces an extra keydown for Control // The modifier does not change if (keyDef.key === 'AltGraph') { - const ctrlKeyDef = keyboardMap.find( + const ctrlKeyDef = config.keyboardMap.find( k => k.key === 'Control', ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - fireEvent.keyDown(element, getKeyEventProps(ctrlKeyDef, keyboardState)) + dispatchUIEvent( + config, + element, + 'keydown', + getKeyEventProps(ctrlKeyDef), + ) } }, }, @@ -82,11 +87,11 @@ export const postKeyupBehavior: behaviorPlugin[] = [ // The modifier does not change { matches: keyDef => keyDef.key === 'AltGraph', - handle: (keyDef, element, {keyboardMap, keyboardState}) => { - const ctrlKeyDef = keyboardMap.find( + handle: (keyDef, element, config) => { + const ctrlKeyDef = config.keyboardMap.find( k => k.key === 'Control', ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - fireEvent.keyUp(element, getKeyEventProps(ctrlKeyDef, keyboardState)) + dispatchUIEvent(config, element, 'keyup', getKeyEventProps(ctrlKeyDef)) }, }, ] diff --git a/src/keyboard/shared/fireChangeForInputTimeIfValid.ts b/src/keyboard/shared/fireChangeForInputTimeIfValid.ts index 3a567698..9c59a096 100644 --- a/src/keyboard/shared/fireChangeForInputTimeIfValid.ts +++ b/src/keyboard/shared/fireChangeForInputTimeIfValid.ts @@ -1,12 +1,16 @@ -import {fireEvent} from '@testing-library/dom' +import {setUIValue} from '../../document' +import {dispatchUIEvent} from '../../event' +import {Config} from '../../setup' import {isValidInputTimeValue} from '../../utils' export function fireChangeForInputTimeIfValid( + config: Config, el: HTMLInputElement & {type: 'time'}, prevValue: unknown, timeNewEntry: string, ) { if (isValidInputTimeValue(el, timeNewEntry) && prevValue !== timeNewEntry) { - fireEvent.change(el, {target: {value: timeNewEntry}}) + setUIValue(el, timeNewEntry) + dispatchUIEvent(config, el, 'change') } } diff --git a/src/pointer/firePointerEvents.ts b/src/pointer/firePointerEvents.ts new file mode 100644 index 00000000..aa1e6729 --- /dev/null +++ b/src/pointer/firePointerEvents.ts @@ -0,0 +1,48 @@ +import {dispatchUIEvent, EventType, PointerCoords} from '../event' +import {Config} from '../setup' +import {getMouseButton, getMouseButtons, MouseButton} from '../utils' + +export function firePointerEvent( + config: Config, + target: Element, + type: EventType, + { + pointerType, + button, + coords, + pointerId, + isPrimary, + clickCount, + }: { + pointerType?: 'mouse' | 'pen' | 'touch' + button?: MouseButton + coords?: PointerCoords + pointerId?: number + isPrimary?: boolean + clickCount?: number + }, +) { + const init: MouseEventInit & PointerEventInit = { + ...coords, + } + if (type === 'click' || type.startsWith('pointer')) { + init.pointerId = pointerId + init.pointerType = pointerType + } + if (['pointerdown', 'pointerup'].includes(type)) { + init.isPrimary = isPrimary + } + init.button = getMouseButton(button ?? 0) + init.buttons = getMouseButtons( + ...config.pointerState.pressed + .filter(p => p.keyDef.pointerType === pointerType) + .map(p => p.keyDef.button ?? 0), + ) + if ( + ['mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu'].includes(type) + ) { + init.detail = clickCount + } + + return dispatchUIEvent(config, target, type, init) +} diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts index bc1c46cb..a60a91d2 100644 --- a/src/pointer/pointerMove.ts +++ b/src/pointer/pointerMove.ts @@ -1,14 +1,14 @@ import {setUISelection} from '../document' +import {EventType, PointerCoords} from '../event' import {Config} from '../setup' import { - PointerCoords, - firePointerEvent, isDescendantOrSelf, isDisabled, assertPointerEvents, setLevelRef, ApiLevel, } from '../utils' +import {firePointerEvent} from './firePointerEvents' import {resolveSelectionTarget} from './resolveSelectionTarget' import {PointerTarget, SelectionTarget} from './types' @@ -20,7 +20,7 @@ export async function pointerMove( config: Config, {pointerName = 'mouse', target, coords, node, offset}: PointerMoveAction, ): Promise { - const {pointerState, keyboardState} = config + const {pointerState} = config if (!(pointerName in pointerState.position)) { throw new Error( `Trying to move pointer "${pointerName}" which does not exist.`, @@ -133,12 +133,10 @@ export async function pointerMove( function fire( eventTarget: Element, - type: string, + type: EventType, eventCoords?: PointerCoords, ) { - return firePointerEvent(eventTarget, type, { - pointerState, - keyboardState, + return firePointerEvent(config, eventTarget, type, { coords: eventCoords, pointerId, pointerType, diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index 004e767d..b9c1db7f 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -3,7 +3,6 @@ import { ApiLevel, assertPointerEvents, - firePointerEvent, focus, isDisabled, isElementType, @@ -11,6 +10,7 @@ import { setLevelRef, } from '../utils' import {getUIValue, setUISelection} from '../document' +import {EventType} from '../event' import {Config} from '../setup' import type { pointerKey, @@ -19,6 +19,7 @@ import type { SelectionTarget, } from './types' import {resolveSelectionTarget} from './resolveSelectionTarget' +import {firePointerEvent} from './firePointerEvents' export interface PointerPressAction extends PointerTarget, SelectionTarget { keyDef: pointerKey @@ -65,7 +66,7 @@ function down( setLevelRef(config, ApiLevel.Trigger) assertPointerEvents(config, target) - const {pointerState, keyboardState} = config + const {pointerState} = config const {name, pointerType, button} = keyDef const pointerId = pointerType === 'mouse' ? 1 : getNextPointerId(pointerState) @@ -146,10 +147,8 @@ function down( return pressObj - function fire(type: string) { - return firePointerEvent(target, type, { - pointerState, - keyboardState, + function fire(type: EventType) { + return firePointerEvent(config, target, type, { button, clickCount, coords, @@ -176,7 +175,7 @@ function up( setLevelRef(config, ApiLevel.Trigger) assertPointerEvents(config, target) - const {pointerState, keyboardState} = config + const {pointerState} = config pointerState.pressed = pointerState.pressed.filter(p => p !== pressed) const {isMultiTouch, isPrimary, pointerId, clickCount} = pressed @@ -233,22 +232,21 @@ function up( const canClick = pointerType !== 'mouse' || button === 'primary' if (canClick && target === pressed.downTarget) { const unpreventedClick = fire('click') + if (clickCount === 2) { fire('dblclick') } - - const control = target.closest('label')?.control - if (unpreventedClick && control && isFocusable(control)) { - focus(control) + if (unpreventedClick) { + clickDefaultBehavior({ + target, + }) } } } } - function fire(type: string) { - return firePointerEvent(target, type, { - pointerState, - keyboardState, + function fire(type: EventType) { + return firePointerEvent(config, target, type, { button, clickCount, coords, @@ -369,3 +367,10 @@ function getTextRange( (text.substr(pos).match(/^[^\r\n]*/) as RegExpMatchArray)[0].length, ] } + +function clickDefaultBehavior({target}: {target: Element}) { + const control = target.closest('label')?.control + if (control && isFocusable(control)) { + focus(control) + } +} diff --git a/src/pointer/types.ts b/src/pointer/types.ts index babe9f2b..3ba057d4 100644 --- a/src/pointer/types.ts +++ b/src/pointer/types.ts @@ -1,4 +1,5 @@ -import {PointerCoords, MouseButton} from '../utils' +import {PointerCoords} from '../event' +import {MouseButton} from '../utils' /** * @internal Do not create/alter this by yourself as this type might be subject to changes. diff --git a/src/setup/index.ts b/src/setup/index.ts index e547b34b..a1e15f3f 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,3 +1,4 @@ +import type {bindDispatchUIEvent} from '../event' import type * as userEventApi from './api' import {setupMain, setupSub} from './setup' import {Config, inputDeviceState} from './config' @@ -10,6 +11,7 @@ export type UserEventApi = typeof userEventApi export type Instance = UserEventApi & { [Config]: Config + dispatchUIEvent: ReturnType } export type UserEvent = { diff --git a/src/setup/setup.ts b/src/setup/setup.ts index b0cad1a0..54316549 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -1,4 +1,5 @@ import {prepareDocument} from '../document' +import {bindDispatchUIEvent} from '../event' import {createKeyboardState} from '../keyboard' import {createPointerState} from '../pointer' import {defaultOptionsDirect, defaultOptionsSetup, Options} from '../options' @@ -13,38 +14,47 @@ import {Config} from './config' import * as userEventApi from './api' import {wrapAsync} from './wrapAsync' +export function createConfig( + options: Partial = {}, + defaults: Required = defaultOptionsSetup, + node?: Node, +): Config { + const document = getDocument(options, node) + + const { + keyboardState = createKeyboardState(), + pointerState = createPointerState(document), + } = options + + return { + ...defaults, + ...options, + document, + keyboardState, + pointerState, + } +} + /** * Start a "session" with userEvent. * All APIs returned by this function share an input device state and a default configuration. */ export function setupMain(options: Options = {}) { - const doc = getDocument(options) - prepareDocument(doc) + const config = createConfig(options) + prepareDocument(config.document) - const view = doc.defaultView ?? /* istanbul ignore next */ window + const view = config.document.defaultView ?? /* istanbul ignore next */ window attachClipboardStubToView(view) - return doSetup({ - ...defaultOptionsSetup, - ...options, - keyboardState: createKeyboardState(), - pointerState: createPointerState(doc), - }) + return doSetup(config) } /** * Setup in direct call per `userEvent.anyApi()` */ export function setupDirect(options: Partial = {}, node?: Node) { - const doc = getDocument(options, node) - prepareDocument(doc) - - const config: Config = { - keyboardState: createKeyboardState(), - pointerState: createPointerState(doc), - ...defaultOptionsDirect, - ...options, - } + const config = createConfig(options, defaultOptionsDirect, node) + prepareDocument(config.document) return { config, @@ -79,6 +89,7 @@ function wrapAndBindImpl< function doSetup(config: Config): UserEvent { const instance: Instance = { [Config]: config, + dispatchUIEvent: bindDispatchUIEvent(config), ...userEventApi, } return { diff --git a/src/utility/clear.ts b/src/utility/clear.ts index faea87c0..de3030f9 100644 --- a/src/utility/clear.ts +++ b/src/utility/clear.ts @@ -1,4 +1,4 @@ -import type {Instance} from '../setup' +import {Config, Instance} from '../setup' import { focus, isAllSelected, @@ -25,5 +25,5 @@ export async function clear(this: Instance, element: Element) { throw new Error('The element content to be cleared could not be selected.') } - prepareInput('', element, 'deleteContentBackward')?.commit() + prepareInput(this[Config], '', element, 'deleteContentBackward')?.commit() } diff --git a/src/utility/selectOptions.ts b/src/utility/selectOptions.ts index 4d1bf9e8..04e44ef2 100644 --- a/src/utility/selectOptions.ts +++ b/src/utility/selectOptions.ts @@ -1,4 +1,4 @@ -import {createEvent, getConfig, fireEvent} from '@testing-library/dom' +import {getConfig} from '@testing-library/dom' import {focus, hasPointerEvents, isDisabled, isElementType} from '../utils' import {Config, Instance} from '../setup' @@ -58,6 +58,16 @@ async function selectOptionsBase( if (isDisabled(select) || !selectedOptions.length) return + const selectOption = (option: HTMLOptionElement) => { + option.selected = newValue + this.dispatchUIEvent(select, 'input', { + bubbles: true, + cancelable: false, + composed: true, + }) + this.dispatchUIEvent(select, 'change') + } + if (isElementType(select, 'select')) { if (select.multiple) { for (const option of selectedOptions) { @@ -68,27 +78,27 @@ async function selectOptionsBase( // events fired for multiple select are weird. Can't use hover... if (withPointerEvents) { - fireEvent.pointerOver(option) - fireEvent.pointerEnter(select) - fireEvent.mouseOver(option) - fireEvent.mouseEnter(select) - fireEvent.pointerMove(option) - fireEvent.mouseMove(option) - fireEvent.pointerDown(option) - fireEvent.mouseDown(option) + this.dispatchUIEvent(option, 'pointerover') + this.dispatchUIEvent(select, 'pointerenter') + this.dispatchUIEvent(option, 'mouseover') + this.dispatchUIEvent(select, 'mouseenter') + this.dispatchUIEvent(option, 'pointermove') + this.dispatchUIEvent(option, 'mousemove') + this.dispatchUIEvent(option, 'pointerdown') + this.dispatchUIEvent(option, 'mousedown') } focus(select) if (withPointerEvents) { - fireEvent.pointerUp(option) - fireEvent.mouseUp(option) + this.dispatchUIEvent(option, 'pointerup') + this.dispatchUIEvent(option, 'mouseup') } selectOption(option as HTMLOptionElement) if (withPointerEvents) { - fireEvent.click(option) + this.dispatchUIEvent(option, 'click') } } } else if (selectedOptions.length === 1) { @@ -106,13 +116,13 @@ async function selectOptionsBase( if (withPointerEvents) { // the browser triggers another click event on the select for the click on the option // this second click has no 'down' phase - fireEvent.pointerOver(select) - fireEvent.pointerEnter(select) - fireEvent.mouseOver(select) - fireEvent.mouseEnter(select) - fireEvent.pointerUp(select) - fireEvent.mouseUp(select) - fireEvent.click(select) + this.dispatchUIEvent(select, 'pointerover') + this.dispatchUIEvent(select, 'pointerenter') + this.dispatchUIEvent(select, 'mouseover') + this.dispatchUIEvent(select, 'mouseenter') + this.dispatchUIEvent(select, 'pointerup') + this.dispatchUIEvent(select, 'mouseup') + this.dispatchUIEvent(select, 'click') } } else { throw getConfig().getElementError( @@ -131,17 +141,4 @@ async function selectOptionsBase( select, ) } - - function selectOption(option: HTMLOptionElement) { - option.selected = newValue - fireEvent( - select, - createEvent('input', select, { - bubbles: true, - cancelable: false, - composed: true, - }), - ) - fireEvent.change(select) - } } diff --git a/src/utility/upload.ts b/src/utility/upload.ts index 604e7b5f..669b4bf4 100644 --- a/src/utility/upload.ts +++ b/src/utility/upload.ts @@ -1,4 +1,3 @@ -import {fireEvent} from '@testing-library/dom' import { blur, createFileList, @@ -51,8 +50,8 @@ export async function upload( } setFiles(input, createFileList(files)) - fireEvent.input(input) - fireEvent.change(input) + this.dispatchUIEvent(input, 'input') + this.dispatchUIEvent(input, 'change') } function isAcceptableFile(file: File, accept: string) { diff --git a/src/utils/edit/editInputElement.ts b/src/utils/edit/editInputElement.ts index cfa894e8..b26c2f07 100644 --- a/src/utils/edit/editInputElement.ts +++ b/src/utils/edit/editInputElement.ts @@ -1,5 +1,6 @@ -import {fireEvent} from '@testing-library/dom' import {setUIValue, startTrackValue, endTrackValue} from '../../document' +import {dispatchUIEvent} from '../../event' +import {Config} from '../../setup' import {setSelection} from '../focus/selection' /** @@ -8,6 +9,7 @@ import {setSelection} from '../focus/selection' * Fires the input event. */ export function editInputElement( + config: Config, element: HTMLInputElement | HTMLTextAreaElement, { newValue, @@ -19,9 +21,7 @@ export function editInputElement( node: Node offset: number } - eventOverrides: Partial[1]> & { - [k: string]: unknown - } + eventOverrides: InputEventInit }, ) { const oldValue = element.value @@ -43,9 +43,7 @@ export function editInputElement( // why the batched update is executed differently in our test environment. startTrackValue(element as HTMLInputElement) - fireEvent.input(element, { - ...eventOverrides, - }) + dispatchUIEvent(config, element, 'input', eventOverrides) const tracked = endTrackValue(element as HTMLInputElement) if ( diff --git a/src/utils/edit/prepareInput.ts b/src/utils/edit/prepareInput.ts index 164b496c..6a6496a5 100644 --- a/src/utils/edit/prepareInput.ts +++ b/src/utils/edit/prepareInput.ts @@ -1,8 +1,10 @@ -import {fireEvent} from '@testing-library/dom' +import {dispatchUIEvent} from '../../event' +import {Config} from '../../setup' import {calculateNewValue, editInputElement, getInputRange} from '../../utils' import {getNextCursorPosition} from '../focus/cursor' export function prepareInput( + config: Config, data: string, element: Element, inputType: string = 'insertText', @@ -60,7 +62,7 @@ export function prepareInput( } if (del || data) { - fireEvent.input(element, {inputType}) + dispatchUIEvent(config, element, 'input', {inputType}) } }, } @@ -89,7 +91,7 @@ export function prepareInput( return } - editInputElement(element as HTMLTextAreaElement, { + editInputElement(config, element as HTMLTextAreaElement, { newValue, newSelection: { node: element, diff --git a/src/utils/index.ts b/src/utils/index.ts index 4944b566..b82200ef 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -45,5 +45,4 @@ export * from './misc/wait' export * from './misc/hasFormSubmit' export * from './pointer/cssPointerEvents' -export * from './pointer/firePointerEvents' export * from './pointer/mouseButtons' diff --git a/src/utils/keyboard/getKeyEventProps.ts b/src/utils/keyboard/getKeyEventProps.ts index e51cb982..7276c98a 100644 --- a/src/utils/keyboard/getKeyEventProps.ts +++ b/src/utils/keyboard/getKeyEventProps.ts @@ -1,10 +1,8 @@ -import {keyboardKey, keyboardState} from '../../keyboard/types' -import {getUIEventModifiers} from './getUIEventModifiers' +import {keyboardKey} from '../../keyboard/types' -export function getKeyEventProps(keyDef: keyboardKey, state: keyboardState) { +export function getKeyEventProps(keyDef: keyboardKey) { return { key: keyDef.key, code: keyDef.code, - ...getUIEventModifiers(state), } } diff --git a/src/utils/pointer/dom-events.d.ts b/src/utils/pointer/dom-events.d.ts deleted file mode 100644 index 7c3e7abd..00000000 --- a/src/utils/pointer/dom-events.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module '@testing-library/dom/dist/event-map.js' { - export const eventMap: Record -} diff --git a/src/utils/pointer/firePointerEvents.ts b/src/utils/pointer/firePointerEvents.ts deleted file mode 100644 index c5261937..00000000 --- a/src/utils/pointer/firePointerEvents.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {createEvent, fireEvent} from '@testing-library/dom' -import {eventMap} from '@testing-library/dom/dist/event-map.js' -import type {pointerState} from '../../pointer/types' -import {getUIEventModifiers} from '../keyboard/getUIEventModifiers' -import type {keyboardState} from '../../keyboard/types' -import {getMouseButton, getMouseButtons, MouseButton} from './mouseButtons' - -export function firePointerEvent( - target: Element, - type: string, - { - pointerState, - keyboardState, - pointerType, - button, - coords, - pointerId, - isPrimary, - clickCount, - }: { - pointerState: pointerState - keyboardState: keyboardState - pointerType?: 'mouse' | 'pen' | 'touch' - button?: MouseButton - coords?: PointerCoords - pointerId?: number - isPrimary?: boolean - clickCount?: number - }, -) { - const init: MouseEventInit & PointerEventInit = { - ...coords, - ...getUIEventModifiers(keyboardState), - } - if (type === 'click' || type.startsWith('pointer')) { - init.pointerId = pointerId - init.pointerType = pointerType - } - if (['pointerdown', 'pointerup'].includes(type)) { - init.isPrimary = isPrimary - } - init.button = getMouseButton(button ?? 0) - init.buttons = getMouseButtons( - ...pointerState.pressed - .filter(p => p.keyDef.pointerType === pointerType) - .map(p => p.keyDef.button ?? 0), - ) - if ( - ['mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu'].includes(type) - ) { - init.detail = clickCount - } - - const eventKey = Object.keys(eventMap).find(k => k.toLowerCase() === type) - const event = createEvent[eventKey as keyof typeof createEvent](target, init) - - // see https://github.com/testing-library/react-testing-library/issues/268 - assignPositionInit(event as MouseEvent, init) - assignPointerInit(event as PointerEvent, init) - - return fireEvent(target, event) -} - -export interface PointerCoords { - x?: number - y?: number - clientX?: number - clientY?: number - offsetX?: number - offsetY?: number - pageX?: number - pageY?: number - screenX?: number - screenY?: number -} - -function assignProps( - obj: MouseEvent | PointerEvent, - props: MouseEventInit & PointerEventInit & PointerCoords, -) { - for (const [key, value] of Object.entries(props)) { - Object.defineProperty(obj, key, {get: () => value}) - } -} - -function assignPositionInit( - obj: MouseEvent | PointerEvent, - { - x, - y, - clientX, - clientY, - offsetX, - offsetY, - pageX, - pageY, - screenX, - screenY, - }: PointerCoords, -) { - assignProps(obj, { - /* istanbul ignore start */ - x: x ?? clientX ?? 0, - y: y ?? clientY ?? 0, - clientX: x ?? clientX ?? 0, - clientY: y ?? clientY ?? 0, - offsetX: offsetX ?? 0, - offsetY: offsetY ?? 0, - pageX: pageX ?? 0, - pageY: pageY ?? 0, - screenX: screenX ?? 0, - screenY: screenY ?? 0, - /* istanbul ignore end */ - }) -} - -function assignPointerInit( - obj: MouseEvent | PointerEvent, - {isPrimary, pointerId, pointerType}: PointerEventInit, -) { - assignProps(obj, { - isPrimary, - pointerId, - pointerType, - }) -} diff --git a/tests/__mocks__/@testing-library/dom.js b/tests/__mocks__/@testing-library/dom.js deleted file mode 100644 index 10fc4d71..00000000 --- a/tests/__mocks__/@testing-library/dom.js +++ /dev/null @@ -1,50 +0,0 @@ -// this helps us track what the state is before and after an event is fired -// this is needed for determining the snapshot values -const actual = jest.requireActual('@testing-library/dom') - -function getTrackedElementValues(element) { - return { - value: element.value, - checked: element.checked, - selectionStart: element.selectionStart, - selectionEnd: element.selectionEnd, - - // unfortunately, changing a select option doesn't happen within fireEvent - // but rather imperatively via `options.selected = newValue` - // because of this we don't (currently) have a way to track before/after - // in a given fireEvent call. - } -} - -function wrapWithTestData(fn) { - return (element, init) => { - const before = getTrackedElementValues(element) - const testData = {before} - - // put it on the element so the event handler can grab it - element.testData = testData - const result = fn(element, init) - - const after = getTrackedElementValues(element) - Object.assign(testData, {after}) - - // elete the testData for the next event - delete element.testData - return result - } -} - -const mockFireEvent = wrapWithTestData(actual.fireEvent) - -for (const key of Object.keys(actual.fireEvent)) { - if (typeof actual.fireEvent[key] === 'function') { - mockFireEvent[key] = wrapWithTestData(actual.fireEvent[key], key) - } else { - mockFireEvent[key] = actual.fireEvent[key] - } -} - -module.exports = { - ...actual, - fireEvent: mockFireEvent, -} diff --git a/tests/_helpers/trackProps.ts b/tests/_helpers/trackProps.ts new file mode 100644 index 00000000..91e76566 --- /dev/null +++ b/tests/_helpers/trackProps.ts @@ -0,0 +1,59 @@ +import {wrapEvent} from '#src/event/wrapEvent' + +declare global { + interface Element { + testData?: TestData + } +} + +export type TestDataProps = { + value?: string + checked?: boolean + selectionStart?: number | null + selectionEnd?: number | null +} + +export type TestData = { + handled?: boolean + + // Where is this assigned? + before?: TestDataProps + after?: TestDataProps +} + +jest.mock('#src/event/wrapEvent', () => ({ + wrapEvent(...[cb, element]: Parameters) { + const before = getTrackedElementValues(element as TestDataProps) + const testData = {before} + + // put it on the element so the event handler can grab it + element.testData = testData + + const result = jest + .requireActual<{ + wrapEvent: typeof wrapEvent + }>('#src/event/wrapEvent') + .wrapEvent(cb, element) + + const after = getTrackedElementValues(element as TestDataProps) + Object.assign(testData, {after}) + + // elete the testData for the next event + delete element.testData + return result + + function getTrackedElementValues(el: TestDataProps): TestDataProps { + return { + value: el.value, + checked: el.checked, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + + // unfortunately, changing a select option doesn't happen within fireEvent + // but rather imperatively via `options.selected = newValue` + // because of this we don't (currently) have a way to track before/after + // in a given fireEvent call. + } + } + }, +})) diff --git a/tests/_helpers/utils.ts b/tests/_helpers/utils.ts index 95e47a00..14331359 100644 --- a/tests/_helpers/utils.ts +++ b/tests/_helpers/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable testing-library/no-node-access */ import {eventMap} from '@testing-library/dom/dist/event-map' +import {TestData, TestDataProps} from './trackProps' import {isElementType, MouseButton} from '#src/utils' + // this is pretty helpful: // https://codesandbox.io/s/quizzical-worker-eo909 @@ -227,14 +229,6 @@ type CallData = { testData?: TestData } -type TestData = { - handled?: boolean - - // Where is this assigned? - before?: Element - after?: Element -} - function isElement(target: EventTarget): target is Element { return 'tagName' in target } @@ -340,14 +334,12 @@ function addListeners( generalListener.mockClear() eventHandlerCalls.current = [] } - const getEvents = ( + const getEvents = ( type?: T, - ): Array => + ): Array => generalListener.mock.calls .map(([e]) => e) - .filter(e => !type || e.type === type) as Array< - GlobalEventHandlersEventMap[T] - > + .filter(e => !type || e.type === type) as Array const eventWasFired = (eventType: keyof GlobalEventHandlersEventMap) => getEvents(eventType).length > 0 @@ -371,19 +363,21 @@ function addListeners( } } -function getValueWithSelection(element?: Element) { - const {value, selectionStart, selectionEnd} = element as HTMLInputElement - +function getValueWithSelection({ + value, + selectionStart, + selectionEnd, +}: TestDataProps = {}) { return [ - value.slice(0, selectionStart ?? undefined), + value?.slice(0, selectionStart ?? undefined), ...(selectionStart === selectionEnd ? ['{CURSOR}'] : [ '{SELECTION}', - value.slice(selectionStart ?? 0, selectionEnd ?? undefined), + value?.slice(selectionStart ?? 0, selectionEnd ?? undefined), '{/SELECTION}', ]), - value.slice(selectionEnd ?? undefined), + value?.slice(selectionEnd ?? undefined), ].join('') } diff --git a/tests/_setup-env.js b/tests/_setup-env.js index 24264f16..48498e38 100644 --- a/tests/_setup-env.js +++ b/tests/_setup-env.js @@ -1,6 +1,7 @@ import '@testing-library/jest-dom/extend-expect' import isCI from 'is-ci' import jestSerializerAnsi from 'jest-serializer-ansi' +import './_helpers/trackProps' expect.addSnapshotSerializer(jestSerializerAnsi) diff --git a/tests/utils/edit/prepareInput.ts b/tests/utils/edit/prepareInput.ts index e0d70aa9..d611e2c5 100644 --- a/tests/utils/edit/prepareInput.ts +++ b/tests/utils/edit/prepareInput.ts @@ -1,6 +1,7 @@ import cases from 'jest-in-case' import {prepareInput} from '#src/utils' import {setup} from '#testHelpers/utils' +import {createConfig} from '#src/setup/setup' cases( 'on input element', @@ -8,7 +9,7 @@ cases( const {element} = setup(``) element.setSelectionRange(range[0], range[1]) - prepareInput(input, element, inputType)?.commit() + prepareInput(createConfig(), input, element, inputType)?.commit() expect(element).toHaveValue(value) }, @@ -55,7 +56,7 @@ cases( range[1], ) - prepareInput(input, element, inputType)?.commit() + prepareInput(createConfig(), input, element, inputType)?.commit() expect(element).toHaveTextContent(textContent) }, @@ -115,7 +116,7 @@ cases( range[1], ) - prepareInput(input, element, inputType)?.commit() + prepareInput(createConfig(), input, element, inputType)?.commit() expect(element.innerHTML).toBe(html) }, From 214fd03e71b23e0bb1ea90f6299008701600e533 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Mon, 7 Feb 2022 11:46:20 +0100 Subject: [PATCH 54/84] fix(upload): fix order of events (#847) --- src/event/behavior/click.ts | 25 +++++++++++++++ src/event/behavior/index.ts | 4 +++ src/event/behavior/registry.ts | 15 +++++++++ src/event/dispatchEvent.ts | 33 +++++++++++++++++++ src/event/index.ts | 3 +- src/pointer/pointerPress.ts | 15 +-------- src/utility/upload.ts | 40 ++++++++++------------- src/utils/index.ts | 1 + src/utils/misc/cloneEvent.ts | 7 ++++ tests/event/dispatchEvent.ts | 58 ++++++++++++++++++++++++++++++++++ tests/utility/upload.ts | 26 ++++++++++++--- 11 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 src/event/behavior/click.ts create mode 100644 src/event/behavior/index.ts create mode 100644 src/event/behavior/registry.ts create mode 100644 src/event/dispatchEvent.ts create mode 100644 src/utils/misc/cloneEvent.ts create mode 100644 tests/event/dispatchEvent.ts diff --git a/src/event/behavior/click.ts b/src/event/behavior/click.ts new file mode 100644 index 00000000..2285b961 --- /dev/null +++ b/src/event/behavior/click.ts @@ -0,0 +1,25 @@ +import {blur, cloneEvent, focus, isElementType, isFocusable} from '../../utils' +import {dispatchEvent} from '../dispatchEvent' +import {behavior} from './registry' + +behavior.click = (event, target, config) => { + const control = target.closest('label')?.control + if (control) { + return () => { + if (isFocusable(control)) { + focus(control) + } + dispatchEvent(config, control, cloneEvent(event)) + } + } else if (isElementType(target, 'input', {type: 'file'})) { + return () => { + // blur fires when the file selector pops up + blur(target) + + target.dispatchEvent(new Event('fileDialog')) + + // focus fires after the file selector has been closed + focus(target) + } + } +} diff --git a/src/event/behavior/index.ts b/src/event/behavior/index.ts new file mode 100644 index 00000000..9a96291d --- /dev/null +++ b/src/event/behavior/index.ts @@ -0,0 +1,4 @@ +import './click' + +export {behavior} from './registry' +export type {BehaviorPlugin} from './registry' diff --git a/src/event/behavior/registry.ts b/src/event/behavior/registry.ts new file mode 100644 index 00000000..a58327e1 --- /dev/null +++ b/src/event/behavior/registry.ts @@ -0,0 +1,15 @@ +import {Config} from '../../setup' +import {EventType} from '../types' + +export interface BehaviorPlugin { + ( + event: DocumentEventMap[Type], + target: Element, + config: Config, + ): // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + void | (() => void) +} + +export const behavior: { + [Type in EventType]?: BehaviorPlugin +} = {} diff --git a/src/event/dispatchEvent.ts b/src/event/dispatchEvent.ts new file mode 100644 index 00000000..e8580661 --- /dev/null +++ b/src/event/dispatchEvent.ts @@ -0,0 +1,33 @@ +import {Config} from '../setup' +import {EventType} from './types' +import {behavior, BehaviorPlugin} from './behavior' + +export function dispatchEvent(config: Config, target: Element, event: Event) { + const type = event.type as EventType + const behaviorImplementation = ( + behavior[type] as BehaviorPlugin | undefined + )?.(event, target, config) + + if (behaviorImplementation) { + event.preventDefault() + let defaultPrevented = false + Object.defineProperty(event, 'defaultPrevented', { + get: () => defaultPrevented, + }) + Object.defineProperty(event, 'preventDefault', { + value: () => { + defaultPrevented = event.cancelable + }, + }) + + target.dispatchEvent(event) + + if (!defaultPrevented as boolean) { + behaviorImplementation() + } + + return !defaultPrevented + } + + return target.dispatchEvent(event) +} diff --git a/src/event/index.ts b/src/event/index.ts index b475a1d5..b17576ce 100644 --- a/src/event/index.ts +++ b/src/event/index.ts @@ -1,6 +1,7 @@ import {Config} from '../setup' import {getUIEventModifiers} from '../utils' import {createEvent, EventTypeInit} from './createEvent' +import {dispatchEvent} from './dispatchEvent' import {isKeyboardEvent, isMouseEvent} from './eventTypes' import {EventType, PointerCoords} from './types' import {wrapEvent} from './wrapEvent' @@ -22,7 +23,7 @@ export function dispatchUIEvent( const event = createEvent(type, target, init) - return wrapEvent(() => target.dispatchEvent(event), target) + return wrapEvent(() => dispatchEvent(config, target, event), target) } export function bindDispatchUIEvent(config: Config) { diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index b9c1db7f..ad025b0b 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -6,7 +6,6 @@ import { focus, isDisabled, isElementType, - isFocusable, setLevelRef, } from '../utils' import {getUIValue, setUISelection} from '../document' @@ -231,16 +230,11 @@ function up( const canClick = pointerType !== 'mouse' || button === 'primary' if (canClick && target === pressed.downTarget) { - const unpreventedClick = fire('click') + fire('click') if (clickCount === 2) { fire('dblclick') } - if (unpreventedClick) { - clickDefaultBehavior({ - target, - }) - } } } } @@ -367,10 +361,3 @@ function getTextRange( (text.substr(pos).match(/^[^\r\n]*/) as RegExpMatchArray)[0].length, ] } - -function clickDefaultBehavior({target}: {target: Element}) { - const control = target.closest('label')?.control - if (control && isFocusable(control)) { - focus(control) - } -} diff --git a/src/utility/upload.ts b/src/utility/upload.ts index 669b4bf4..72abedbd 100644 --- a/src/utility/upload.ts +++ b/src/utility/upload.ts @@ -1,11 +1,4 @@ -import { - blur, - createFileList, - focus, - isDisabled, - isElementType, - setFiles, -} from '../utils' +import {createFileList, isDisabled, isElementType, setFiles} from '../utils' import {Config, Instance} from '../setup' export interface uploadInit { @@ -28,30 +21,31 @@ export async function upload( } if (isDisabled(element)) return - await this.click(element) - const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]) .filter( file => !this[Config].applyAccept || isAcceptableFile(file, input.accept), ) .slice(0, input.multiple ? undefined : 1) - // blur fires when the file selector pops up - blur(input) - // focus fires when they make their selection - focus(input) + const fileDialog = () => { + // do not fire an input event if the file selection does not change + if ( + files.length === input.files?.length && + files.every((f, i) => f === input.files?.item(i)) + ) { + return + } - // do not fire an input event if the file selection does not change - if ( - files.length === input.files?.length && - files.every((f, i) => f === input.files?.item(i)) - ) { - return + setFiles(input, createFileList(files)) + this.dispatchUIEvent(input, 'input') + this.dispatchUIEvent(input, 'change') } - setFiles(input, createFileList(files)) - this.dispatchUIEvent(input, 'input') - this.dispatchUIEvent(input, 'change') + input.addEventListener('fileDialog', fileDialog) + + await this.click(element) + + input.removeEventListener('fileDialog', fileDialog) } function isAcceptableFile(file: File, accept: string) { diff --git a/src/utils/index.ts b/src/utils/index.ts index b82200ef..4f27529e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -33,6 +33,7 @@ export * from './keyboard/getUIEventModifiers' export * from './keyDef/readNextDescriptor' +export * from './misc/cloneEvent' export * from './misc/eventWrapper' export * from './misc/findClosest' export * from './misc/getDocumentFromNode' diff --git a/src/utils/misc/cloneEvent.ts b/src/utils/misc/cloneEvent.ts new file mode 100644 index 00000000..d59afc53 --- /dev/null +++ b/src/utils/misc/cloneEvent.ts @@ -0,0 +1,7 @@ +interface EventConstructor { + new (type: string, init: EventInit): E +} + +export function cloneEvent(event: E) { + return new (event.constructor as EventConstructor)(event.type, event) +} diff --git a/tests/event/dispatchEvent.ts b/tests/event/dispatchEvent.ts new file mode 100644 index 00000000..ee693a1c --- /dev/null +++ b/tests/event/dispatchEvent.ts @@ -0,0 +1,58 @@ +import {dispatchUIEvent} from '#src/event' +import {behavior, BehaviorPlugin} from '#src/event/behavior' +import {createConfig} from '#src/setup/setup' +import {setup} from '#testHelpers/utils' + +jest.mock('#src/event/behavior', () => ({ + behavior: { + click: jest.fn(), + }, +})) + +const mockPlugin = behavior.click as jest.MockedFunction< + BehaviorPlugin<'click'> +> + +afterEach(() => { + jest.clearAllMocks() +}) + +test('keep default behavior', () => { + const {element} = setup(``) + + dispatchUIEvent(createConfig(), element, 'click') + + expect(mockPlugin).toBeCalledTimes(1) + expect(element).toBeChecked() +}) + +test('replace default behavior', () => { + const {element} = setup(``) + + const mockBehavior = jest.fn() + mockPlugin.mockImplementationOnce(() => mockBehavior) + + dispatchUIEvent(createConfig(), element, 'click') + + expect(mockPlugin).toBeCalledTimes(1) + expect(element).not.toBeChecked() + expect(mockBehavior).toBeCalled() +}) + +test('prevent replaced default behavior', () => { + const {element} = setup(``) + element.addEventListener('click', e => { + expect(e).toHaveProperty('defaultPrevented', false) + e.preventDefault() + expect(e).toHaveProperty('defaultPrevented', true) + }) + + const mockBehavior = jest.fn() + mockPlugin.mockImplementationOnce(() => mockBehavior) + + dispatchUIEvent(createConfig(), element, 'click') + + expect(mockPlugin).toBeCalledTimes(1) + expect(element).not.toBeChecked() + expect(mockBehavior).not.toBeCalled() +}) diff --git a/tests/utility/upload.ts b/tests/utility/upload.ts index 21b00f93..91171ba2 100644 --- a/tests/utility/upload.ts +++ b/tests/utility/upload.ts @@ -29,12 +29,13 @@ test('change file input', async () => { input[value=""] - pointerup input[value=""] - mouseup: primary input[value=""] - click: primary + "{CURSOR}" -> "C:\\\\fakepath\\\\hello.png{CURSOR}C:\\\\fakepath\\\\hello.png" input[value=""] - blur input[value=""] - focusout - input[value=""] - focus - input[value=""] - focusin input[value="C:\\\\fakepath\\\\hello.png"] - input input[value="C:\\\\fakepath\\\\hello.png"] - change + input[value="C:\\\\fakepath\\\\hello.png"] - focus + input[value="C:\\\\fakepath\\\\hello.png"] - focusin `) }) @@ -63,15 +64,32 @@ test('relay click/upload on label to file input', async () => { label[for="element"] - pointerup label[for="element"] - mouseup: primary label[for="element"] - click: primary - input#element[value=""] - click: primary input#element[value=""] - focusin + input#element[value=""] - click: primary input#element[value=""] - focusout - input#element[value=""] - focusin input#element[value="C:\\\\fakepath\\\\hello.png"] - input input#element[value="C:\\\\fakepath\\\\hello.png"] - change + input#element[value="C:\\\\fakepath\\\\hello.png"] - focusin `) }) +test('prevent file dialog per click event handler', async () => { + const file = new File(['hello'], 'hello.png', {type: 'image/png'}) + + const { + elements: [label, input], + eventWasFired, + } = setup<[HTMLLabelElement]>(` + + + `) + input.addEventListener('click', e => e.preventDefault()) + + await userEvent.upload(label, file) + + expect(eventWasFired('input')).toBe(false) +}) + test('upload multiple files', async () => { const files = [ new File(['hello'], 'hello.png', {type: 'image/png'}), From ca4482a16bd0da3f7d7e5684fe6bb4650f26a1ee Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 8 Feb 2022 12:09:16 +0100 Subject: [PATCH 55/84] fix(pointer): consider click context (#850) --- src/event/behavior/click.ts | 3 ++- tests/pointer/click.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/event/behavior/click.ts b/src/event/behavior/click.ts index 2285b961..9dec9ad0 100644 --- a/src/event/behavior/click.ts +++ b/src/event/behavior/click.ts @@ -3,7 +3,8 @@ import {dispatchEvent} from '../dispatchEvent' import {behavior} from './registry' behavior.click = (event, target, config) => { - const control = target.closest('label')?.control + const context = target.closest('button,input,label,textarea') + const control = context && isElementType(context, 'label') && context.control if (control) { return () => { if (isFocusable(control)) { diff --git a/tests/pointer/click.ts b/tests/pointer/click.ts index 11bc8ba3..66bd4d23 100644 --- a/tests/pointer/click.ts +++ b/tests/pointer/click.ts @@ -191,6 +191,26 @@ test('multi touch does not click', async () => { expect(getEvents('click')).toHaveLength(0) }) +describe('label', () => { + test('click associated control per label', async () => { + const {element, getEvents} = setup( + ``, + ) + + await userEvent.pointer({keys: '[MouseLeft]', target: element}) + + expect(getEvents('click')).toHaveLength(2) + }) + + test('click nested control per label', async () => { + const {element, getEvents} = setup(``) + + await userEvent.pointer({keys: '[MouseLeft]', target: element}) + + expect(getEvents('click')).toHaveLength(2) + }) +}) + describe('check/uncheck control per click', () => { test('clicking changes checkbox', async () => { const {element} = setup('') From 8890bd6d17376205f553620148852d63f84d5565 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 10 Feb 2022 14:12:55 +0100 Subject: [PATCH 56/84] feat(event): support `beforeinput` (#851) --- src/clipboard/cut.ts | 4 +- src/clipboard/paste.ts | 11 +- src/event/behavior/index.ts | 3 + src/event/behavior/keydown.ts | 104 +++++++ src/event/behavior/keypress.ts | 68 +++++ src/event/behavior/keyup.ts | 20 ++ src/event/createEvent.ts | 11 +- src/event/dispatchEvent.ts | 22 +- src/event/eventMap.ts | 10 + src/event/eventTypes.ts | 6 +- src/event/index.ts | 4 +- src/keyboard/keyboardAction.ts | 47 +-- src/keyboard/modifiers.ts | 90 ++++++ src/keyboard/plugins/arrow.ts | 57 ---- src/keyboard/plugins/character.ts | 182 ------------ src/keyboard/plugins/combination.ts | 14 - src/keyboard/plugins/control.ts | 53 ---- src/keyboard/plugins/functional.ts | 95 ------ src/keyboard/plugins/index.ts | 33 --- src/keyboard/plugins/modifiers.ts | 97 ------- .../shared/fireChangeForInputTimeIfValid.ts | 16 -- src/keyboard/shared/index.ts | 1 - src/keyboard/types.ts | 7 - src/utility/clear.ts | 4 +- src/utils/edit/calculateNewValue.ts | 69 ----- src/utils/edit/editInputElement.ts | 60 ---- src/utils/edit/input.ts | 272 ++++++++++++++++++ ...DateValue.ts => isValidDateOrTimeValue.ts} | 6 +- src/utils/edit/isValidInputTimeValue.ts | 8 - src/utils/edit/prepareInput.ts | 107 ------- src/utils/focus/selection.ts | 45 +++ src/utils/index.ts | 9 +- src/utils/misc/hasFormSubmit.ts | 6 - tests/_helpers/dom-event-map.d.ts | 9 - tests/_helpers/dom-events.d.ts | 9 + tests/_helpers/utils.ts | 2 +- tests/clipboard/paste.ts | 2 + tests/dom/customElement.ts | 3 + tests/event/behavior/keydown.ts | 17 ++ tests/event/behavior/keyup.ts | 20 ++ tests/keyboard/{plugin => }/arrow.ts | 0 tests/keyboard/{plugin => }/character.ts | 0 tests/keyboard/{plugin => }/combination.ts | 0 tests/keyboard/{plugin => }/control.ts | 0 tests/keyboard/{plugin => }/functional.ts | 13 +- tests/keyboard/index.ts | 9 + tests/keyboard/keyboardAction.ts | 11 + tests/keyboard/{plugin => }/modifiers.ts | 0 tests/keyboard/shared/fireInputEvent.ts | 23 -- tests/react/type.tsx | 5 + tests/utility/clear.ts | 3 + tests/utility/type.ts | 3 + tests/utility/upload.ts | 1 - tests/utils/edit/calculateNewValue.ts | 8 + .../utils/edit/{prepareInput.ts => input.ts} | 59 ++-- 55 files changed, 804 insertions(+), 934 deletions(-) create mode 100644 src/event/behavior/keydown.ts create mode 100644 src/event/behavior/keypress.ts create mode 100644 src/event/behavior/keyup.ts create mode 100644 src/event/eventMap.ts create mode 100644 src/keyboard/modifiers.ts delete mode 100644 src/keyboard/plugins/arrow.ts delete mode 100644 src/keyboard/plugins/character.ts delete mode 100644 src/keyboard/plugins/combination.ts delete mode 100644 src/keyboard/plugins/control.ts delete mode 100644 src/keyboard/plugins/functional.ts delete mode 100644 src/keyboard/plugins/index.ts delete mode 100644 src/keyboard/plugins/modifiers.ts delete mode 100644 src/keyboard/shared/fireChangeForInputTimeIfValid.ts delete mode 100644 src/keyboard/shared/index.ts delete mode 100644 src/utils/edit/calculateNewValue.ts delete mode 100644 src/utils/edit/editInputElement.ts create mode 100644 src/utils/edit/input.ts rename src/utils/edit/{isValidDateValue.ts => isValidDateOrTimeValue.ts} (56%) delete mode 100644 src/utils/edit/isValidInputTimeValue.ts delete mode 100644 src/utils/edit/prepareInput.ts delete mode 100644 src/utils/misc/hasFormSubmit.ts delete mode 100644 tests/_helpers/dom-event-map.d.ts create mode 100644 tests/_helpers/dom-events.d.ts create mode 100644 tests/event/behavior/keydown.ts create mode 100644 tests/event/behavior/keyup.ts rename tests/keyboard/{plugin => }/arrow.ts (100%) rename tests/keyboard/{plugin => }/character.ts (100%) rename tests/keyboard/{plugin => }/combination.ts (100%) rename tests/keyboard/{plugin => }/control.ts (100%) rename tests/keyboard/{plugin => }/functional.ts (96%) rename tests/keyboard/{plugin => }/modifiers.ts (100%) delete mode 100644 tests/keyboard/shared/fireInputEvent.ts rename tests/utils/edit/{prepareInput.ts => input.ts} (75%) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index d1045b66..eee66540 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,8 +1,8 @@ import {Config, Instance} from '../setup' import { copySelection, + input, isEditable, - prepareInput, writeDataTransferToClipboard, } from '../utils' @@ -21,7 +21,7 @@ export async function cut(this: Instance) { }) if (isEditable(target)) { - prepareInput(this[Config], '', target, 'deleteByCut')?.commit() + input(this[Config], target, '', 'deleteByCut') } if (this[Config].writeToClipboard) { diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 694e96d1..31a35e3d 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,10 +1,9 @@ import {Config, Instance} from '../setup' import { createDataTransfer, - getSpaceUntilMaxLength, - prepareInput, isEditable, readDataTransferFromClipboard, + input, } from '../utils' export async function paste( @@ -29,13 +28,7 @@ export async function paste( }) if (isEditable(target)) { - const textData = dataTransfer - .getData('text') - .substr(0, getSpaceUntilMaxLength(target)) - - if (textData) { - prepareInput(this[Config], textData, target, 'insertFromPaste')?.commit() - } + input(this[Config], target, dataTransfer.getData('text'), 'insertFromPaste') } } diff --git a/src/event/behavior/index.ts b/src/event/behavior/index.ts index 9a96291d..a6ef420d 100644 --- a/src/event/behavior/index.ts +++ b/src/event/behavior/index.ts @@ -1,4 +1,7 @@ import './click' +import './keydown' +import './keypress' +import './keyup' export {behavior} from './registry' export type {BehaviorPlugin} from './registry' diff --git a/src/event/behavior/keydown.ts b/src/event/behavior/keydown.ts new file mode 100644 index 00000000..1192424a --- /dev/null +++ b/src/event/behavior/keydown.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import {setUISelection} from '../../document' +import { + focus, + getTabDestination, + getValue, + hasOwnSelection, + input, + isContentEditable, + isEditable, + isElementType, + moveSelection, + selectAll, + setSelectionRange, +} from '../../utils' +import {BehaviorPlugin} from '.' +import {behavior} from './registry' + +behavior.keydown = (event, target, config) => { + return ( + keydownBehavior[event.key]?.(event, target, config) ?? + combinationBehavior(event, target, config) + ) +} + +const keydownBehavior: { + [key: string]: BehaviorPlugin<'keydown'> | undefined +} = { + ArrowLeft: (event, target) => () => moveSelection(target, -1), + ArrowRight: (event, target) => () => moveSelection(target, 1), + Backspace: (event, target, config) => { + if (isEditable(target)) { + return () => { + input(config, target, '', 'deleteContentBackward') + } + } + }, + Delete: (event, target, config) => { + if (isEditable(target)) { + return () => { + input(config, target, '', 'deleteContentForward') + } + } + }, + End: (event, target) => { + if ( + isElementType(target, ['input', 'textarea']) || + isContentEditable(target) + ) { + return () => { + const newPos = getValue(target)?.length ?? /* istanbul ignore next */ 0 + setSelectionRange(target, newPos, newPos) + } + } + }, + Home: (event, target) => { + if ( + isElementType(target, ['input', 'textarea']) || + isContentEditable(target) + ) { + return () => { + setSelectionRange(target, 0, 0) + } + } + }, + PageDown: (event, target) => { + if (isElementType(target, ['input'])) { + return () => { + const newPos = getValue(target).length + setSelectionRange(target, newPos, newPos) + } + } + }, + PageUp: (event, target) => { + if (isElementType(target, ['input'])) { + return () => { + setSelectionRange(target, 0, 0) + } + } + }, + Tab: (event, target, {keyboardState}) => { + return () => { + const dest = getTabDestination(target, keyboardState.modifiers.Shift) + focus(dest) + if (hasOwnSelection(dest)) { + setUISelection(dest, { + anchorOffset: 0, + focusOffset: dest.value.length, + }) + } + } + }, +} + +const combinationBehavior: BehaviorPlugin<'keydown'> = ( + event, + target, + config, +) => { + if (event.code === 'KeyA' && config.keyboardState.modifiers.Control) { + return () => selectAll(target) + } +} diff --git a/src/event/behavior/keypress.ts b/src/event/behavior/keypress.ts new file mode 100644 index 00000000..a52b9bc3 --- /dev/null +++ b/src/event/behavior/keypress.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import {dispatchUIEvent} from '..' +import {input, isContentEditable, isEditable, isElementType} from '../../utils' +import {behavior} from './registry' + +behavior.keypress = (event, target, config) => { + if (event.key === 'Enter') { + if ( + isElementType(target, 'button') || + (isElementType(target, 'input') && + ClickInputOnEnter.includes(target.type)) || + (isElementType(target, 'a') && Boolean(target.href)) + ) { + return () => { + dispatchUIEvent(config, target, 'click') + } + } else if (isElementType(target, 'input')) { + const form = target.form + const submit = form?.querySelector( + 'input[type="submit"], button:not([type]), button[type="submit"]', + ) + if (submit) { + return () => dispatchUIEvent(config, submit, 'click') + } else if ( + form && + SubmitSingleInputOnEnter.includes(target.type) && + form.querySelectorAll('input').length === 1 + ) { + return () => dispatchUIEvent(config, form, 'submit') + } else { + return + } + } + } + + if (isEditable(target)) { + const inputType = + event.key === 'Enter' + ? isContentEditable(target) && !config.keyboardState.modifiers.Shift + ? 'insertParagraph' + : 'insertLineBreak' + : 'insertText' + const inputData = event.key === 'Enter' ? '\n' : event.key + + return () => input(config, target, inputData, inputType) + } +} + +const ClickInputOnEnter = [ + 'button', + 'color', + 'file', + 'image', + 'reset', + 'submit', +] + +const SubmitSingleInputOnEnter = [ + 'email', + 'month', + 'password', + 'search', + 'tel', + 'text', + 'url', + 'week', +] diff --git a/src/event/behavior/keyup.ts b/src/event/behavior/keyup.ts new file mode 100644 index 00000000..3c06f75d --- /dev/null +++ b/src/event/behavior/keyup.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import {isClickableInput} from '../../utils' +import {dispatchUIEvent} from '..' +import {BehaviorPlugin} from '.' +import {behavior} from './registry' + +behavior.keyup = (event, target, config) => { + return keyupBehavior[event.key]?.(event, target, config) +} + +const keyupBehavior: { + [key: string]: BehaviorPlugin<'keyup'> | undefined +} = { + ' ': (event, target, config) => { + if (isClickableInput(target)) { + return () => dispatchUIEvent(config, target, 'click') + } + }, +} diff --git a/src/event/createEvent.ts b/src/event/createEvent.ts index d4b27789..646d709b 100644 --- a/src/event/createEvent.ts +++ b/src/event/createEvent.ts @@ -1,5 +1,5 @@ import {createEvent as createEventBase} from '@testing-library/dom' -import {eventMap} from '@testing-library/dom/dist/event-map.js' +import {eventMap} from './eventMap' import {isMouseEvent} from './eventTypes' import {EventType, PointerCoords} from './types' @@ -32,9 +32,14 @@ export function createEvent( ) { const eventKey = Object.keys(eventMap).find( k => k.toLowerCase() === type, - ) as keyof typeof createEventBase + ) as keyof typeof eventMap - const event = createEventBase[eventKey](target, init) as DocumentEventMap[K] + const event = createEventBase( + type, + target, + init, + eventMap[eventKey], + ) as DocumentEventMap[K] // Can not use instanceof, as MouseEvent might be polyfilled. if (isMouseEvent(type) && init) { diff --git a/src/event/dispatchEvent.ts b/src/event/dispatchEvent.ts index e8580661..8619173f 100644 --- a/src/event/dispatchEvent.ts +++ b/src/event/dispatchEvent.ts @@ -1,12 +1,22 @@ import {Config} from '../setup' import {EventType} from './types' import {behavior, BehaviorPlugin} from './behavior' +import {wrapEvent} from './wrapEvent' -export function dispatchEvent(config: Config, target: Element, event: Event) { +export function dispatchEvent( + config: Config, + target: Element, + event: Event, + preventDefault: boolean = false, +) { const type = event.type as EventType - const behaviorImplementation = ( - behavior[type] as BehaviorPlugin | undefined - )?.(event, target, config) + const behaviorImplementation = preventDefault + ? () => {} + : (behavior[type] as BehaviorPlugin | undefined)?.( + event, + target, + config, + ) if (behaviorImplementation) { event.preventDefault() @@ -20,7 +30,7 @@ export function dispatchEvent(config: Config, target: Element, event: Event) { }, }) - target.dispatchEvent(event) + wrapEvent(() => target.dispatchEvent(event), target) if (!defaultPrevented as boolean) { behaviorImplementation() @@ -29,5 +39,5 @@ export function dispatchEvent(config: Config, target: Element, event: Event) { return !defaultPrevented } - return target.dispatchEvent(event) + return wrapEvent(() => target.dispatchEvent(event), target) } diff --git a/src/event/eventMap.ts b/src/event/eventMap.ts new file mode 100644 index 00000000..7ac4b062 --- /dev/null +++ b/src/event/eventMap.ts @@ -0,0 +1,10 @@ +import {eventMap as baseEventMap} from '@testing-library/dom/dist/event-map.js' + +export const eventMap = { + ...baseEventMap, + + beforeInput: { + EventType: 'InputEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, +} diff --git a/src/event/eventTypes.ts b/src/event/eventTypes.ts index 6c02d297..782be400 100644 --- a/src/event/eventTypes.ts +++ b/src/event/eventTypes.ts @@ -3,11 +3,13 @@ import {eventMap} from '@testing-library/dom/dist/event-map.js' const eventKeys = Object.fromEntries( Object.keys(eventMap).map(k => [k.toLowerCase(), k]), ) as { - [k in keyof DocumentEventMap]: keyof typeof eventMap + [k in keyof DocumentEventMap]?: keyof typeof eventMap } function getEventClass(type: keyof DocumentEventMap) { - return eventMap[eventKeys[type]].EventType + return type in eventKeys + ? eventMap[eventKeys[type] as keyof typeof eventMap].EventType + : 'Event' } const mouseEvents = ['MouseEvent', 'PointerEvent'] diff --git a/src/event/index.ts b/src/event/index.ts index b17576ce..0c299102 100644 --- a/src/event/index.ts +++ b/src/event/index.ts @@ -4,7 +4,6 @@ import {createEvent, EventTypeInit} from './createEvent' import {dispatchEvent} from './dispatchEvent' import {isKeyboardEvent, isMouseEvent} from './eventTypes' import {EventType, PointerCoords} from './types' -import {wrapEvent} from './wrapEvent' export type {EventType, PointerCoords} @@ -13,6 +12,7 @@ export function dispatchUIEvent( target: Element, type: K, init?: EventTypeInit, + preventDefault: boolean = false, ) { if (isMouseEvent(type) || isKeyboardEvent(type)) { init = { @@ -23,7 +23,7 @@ export function dispatchUIEvent( const event = createEvent(type, target, init) - return wrapEvent(() => dispatchEvent(config, target, event), target) + return dispatchEvent(config, target, event, preventDefault) } export function bindDispatchUIEvent(config: Config) { diff --git a/src/keyboard/keyboardAction.ts b/src/keyboard/keyboardAction.ts index b29d8dab..2a0f444e 100644 --- a/src/keyboard/keyboardAction.ts +++ b/src/keyboard/keyboardAction.ts @@ -1,8 +1,12 @@ import {dispatchUIEvent} from '../event' import {Config} from '../setup' import {getActiveElement, getKeyEventProps, wait} from '../utils' -import {behaviorPlugin, keyboardKey} from './types' -import * as plugins from './plugins' +import {keyboardKey} from './types' +import { + postKeyupBehavior, + preKeydownBehavior, + preKeyupBehavior, +} from './modifiers' export interface KeyboardAction { keyDef: keyboardKey @@ -83,7 +87,7 @@ async function keydown( } config.keyboardState.activeElement = element - applyPlugins(plugins.preKeydownBehavior, keyDef, element, config) + preKeydownBehavior(config, keyDef, element) const unpreventedDefault = dispatchUIEvent( config, @@ -94,11 +98,6 @@ async function keydown( config.keyboardState.pressed.push({keyDef, unpreventedDefault}) - if (unpreventedDefault) { - // all default behavior like keypress/submit etc is applied to the currentElement - applyPlugins(plugins.keydownBehavior, keyDef, getCurrentElement(), config) - } - return unpreventedDefault } @@ -109,14 +108,10 @@ async function keypress( ) { const element = getCurrentElement() - const unpreventedDefault = dispatchUIEvent(config, element, 'keypress', { + dispatchUIEvent(config, element, 'keypress', { ...getKeyEventProps(keyDef), charCode: keyDef.key === 'Enter' ? 13 : String(keyDef.key).charCodeAt(0), }) - - if (unpreventedDefault) { - applyPlugins(plugins.keypressBehavior, keyDef, getCurrentElement(), config) - } } async function keyup( @@ -127,39 +122,21 @@ async function keyup( ) { const element = getCurrentElement() - applyPlugins(plugins.preKeyupBehavior, keyDef, element, config) + preKeyupBehavior(config, keyDef) - const unpreventedDefault = dispatchUIEvent( + dispatchUIEvent( config, element, 'keyup', getKeyEventProps(keyDef), + !unprevented, ) - if (unprevented && unpreventedDefault) { - applyPlugins(plugins.keyupBehavior, keyDef, getCurrentElement(), config) - } - config.keyboardState.pressed = config.keyboardState.pressed.filter( k => k.keyDef !== keyDef, ) - applyPlugins(plugins.postKeyupBehavior, keyDef, element, config) -} - -function applyPlugins( - pluginCollection: behaviorPlugin[], - keyDef: keyboardKey, - element: Element, - config: Config, -): boolean { - const plugin = pluginCollection.find(p => p.matches(keyDef, element, config)) - - if (plugin) { - plugin.handle(keyDef, element, config) - } - - return !!plugin + postKeyupBehavior(config, keyDef, element) } function hasKeyPress(keyDef: keyboardKey, config: Config) { diff --git a/src/keyboard/modifiers.ts b/src/keyboard/modifiers.ts new file mode 100644 index 00000000..4cdb0337 --- /dev/null +++ b/src/keyboard/modifiers.ts @@ -0,0 +1,90 @@ +/** + * This file should contain behavior for modifier keys: + * https://www.w3.org/TR/uievents-key/#keys-modifier + */ + +import {dispatchUIEvent} from '../event' +import {getKeyEventProps} from '../utils' +import {Config} from '../setup' +import {keyboardKey} from '.' + +const modifierKeys = [ + 'Alt', + 'AltGraph', + 'Control', + 'Fn', + 'Meta', + 'Shift', + 'Symbol', +] as const +type ModififierKey = typeof modifierKeys[number] + +function isModifierKey(key?: string): key is ModififierKey { + return modifierKeys.includes(key as ModififierKey) +} + +const modifierLocks = [ + 'CapsLock', + 'FnLock', + 'NumLock', + 'ScrollLock', + 'SymbolLock', +] as const +type ModififierLockKey = typeof modifierLocks[number] + +function isModifierLock(key?: string): key is ModififierLockKey { + return modifierLocks.includes(key as ModififierLockKey) +} + +// modifierKeys switch on the modifier BEFORE the keydown event +export function preKeydownBehavior( + config: Config, + {key}: keyboardKey, + element: Element, +) { + if (isModifierKey(key)) { + config.keyboardState.modifiers[key] = true + + // AltGraph produces an extra keydown for Control + // The modifier does not change + if (key === 'AltGraph') { + const ctrlKeyDef = config.keyboardMap.find( + k => k.key === 'Control', + ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} + dispatchUIEvent(config, element, 'keydown', getKeyEventProps(ctrlKeyDef)) + } + } else if (isModifierLock(key)) { + config.keyboardState.modifierPhase[key] = + config.keyboardState.modifiers[key] + + if (!config.keyboardState.modifierPhase[key]) { + config.keyboardState.modifiers[key] = true + } + } +} + +// modifierKeys switch off the modifier BEFORE the keyup event +export function preKeyupBehavior(config: Config, {key}: keyboardKey) { + if (isModifierKey(key)) { + config.keyboardState.modifiers[key] = false + } else if (isModifierLock(key)) { + if (config.keyboardState.modifierPhase[key]) { + config.keyboardState.modifiers[key] = false + } + } +} + +export function postKeyupBehavior( + config: Config, + {key}: keyboardKey, + element: Element, +) { + // AltGraph produces an extra keyup for Control + // The modifier does not change + if (key === 'AltGraph') { + const ctrlKeyDef = config.keyboardMap.find( + k => k.key === 'Control', + ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} + dispatchUIEvent(config, element, 'keyup', getKeyEventProps(ctrlKeyDef)) + } +} diff --git a/src/keyboard/plugins/arrow.ts b/src/keyboard/plugins/arrow.ts deleted file mode 100644 index 23bc01d6..00000000 --- a/src/keyboard/plugins/arrow.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * This file should contain behavior for arrow keys as described here: - * https://w3c.github.io/uievents-code/#key-arrowpad-section - */ - -import {behaviorPlugin} from '../types' -import {getNextCursorPosition, hasOwnSelection, setSelection} from '../../utils' -import {getUISelection} from '../../document' - -export const keydownBehavior: behaviorPlugin[] = [ - { - matches: keyDef => - keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight', - handle: (keyDef, element) => { - // TODO: implement shift - - if (hasOwnSelection(element)) { - const selection = getUISelection(element as HTMLInputElement) - - setSelection({ - focusNode: element, - focusOffset: - selection.startOffset === selection.endOffset - ? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1) - : keyDef.key === 'ArrowLeft' - ? selection.startOffset - : selection.endOffset, - }) - } else { - const selection = element.ownerDocument.getSelection() - - /* istanbul ignore if */ - if (!selection) { - return - } - - if (selection.isCollapsed) { - const nextPosition = getNextCursorPosition( - selection.focusNode as Node, - selection.focusOffset, - keyDef.key === 'ArrowLeft' ? -1 : 1, - ) - if (nextPosition) { - setSelection({ - focusNode: nextPosition.node, - focusOffset: nextPosition.offset, - }) - } - } else { - selection[ - keyDef.key === 'ArrowLeft' ? 'collapseToStart' : 'collapseToEnd' - ]() - } - } - }, - }, -] diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts deleted file mode 100644 index 8add133b..00000000 --- a/src/keyboard/plugins/character.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * This file should cover the behavior for keys that produce character input - */ - -import {fireChangeForInputTimeIfValid} from '../shared' -import {behaviorPlugin} from '../types' -import { - buildTimeValue, - calculateNewValue, - editInputElement, - getInputRange, - getSpaceUntilMaxLength, - getValue, - isContentEditable, - isEditableInput, - isElementType, - isValidDateValue, - isValidInputTimeValue, - prepareInput, -} from '../../utils' -import {setUIValue, UISelectionRange} from '../../document' -import {dispatchUIEvent} from '../../event' - -export const keypressBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element) => - keyDef.key?.length === 1 && - isElementType(element, 'input', {type: 'time', readOnly: false}), - handle: (keyDef, element, config) => { - let newEntry = keyDef.key as string - - const textToBeTyped = (config.keyboardState.carryValue ?? '') + newEntry - const timeNewEntry = buildTimeValue(textToBeTyped) - if ( - isValidInputTimeValue( - element as HTMLInputElement & {type: 'time'}, - timeNewEntry, - ) - ) { - newEntry = timeNewEntry - } - - const {newValue, newOffset} = calculateNewValue( - newEntry, - element as HTMLInputElement & {type: 'time'}, - getInputRange(element) as UISelectionRange, - ) - const prevValue = getValue(element) - - // this check was provided by fireInputEventIfNeeded - // TODO: verify if it is even needed by this handler - if (prevValue !== newValue) { - editInputElement(config, element as HTMLInputElement, { - newValue, - newSelection: { - node: element, - offset: newOffset, - }, - eventOverrides: { - data: keyDef.key, - inputType: 'insertText', - }, - }) - } - - fireChangeForInputTimeIfValid( - config, - element as HTMLInputElement & {type: 'time'}, - prevValue, - timeNewEntry, - ) - - config.keyboardState.carryValue = textToBeTyped - }, - }, - { - matches: (keyDef, element) => - keyDef.key?.length === 1 && - isElementType(element, 'input', {type: 'date', readOnly: false}), - handle: (keyDef, element, config) => { - let newEntry = keyDef.key as string - - const textToBeTyped = (config.keyboardState.carryValue ?? '') + newEntry - const isValidToBeTyped = isValidDateValue( - element as HTMLInputElement & {type: 'date'}, - textToBeTyped, - ) - if (isValidToBeTyped) { - newEntry = textToBeTyped - } - - const {newValue, newOffset} = calculateNewValue( - newEntry, - element as HTMLInputElement & {type: 'date'}, - getInputRange(element) as UISelectionRange, - ) - const prevValue = getValue(element) - - // this check was provided by fireInputEventIfNeeded - // TODO: verify if it is even needed by this handler - if (prevValue !== newValue) { - editInputElement(config, element as HTMLInputElement, { - newValue, - newSelection: { - node: element, - offset: newOffset, - }, - eventOverrides: { - data: keyDef.key, - inputType: 'insertText', - }, - }) - } - - if (isValidToBeTyped) { - setUIValue(element as HTMLInputElement, textToBeTyped) - dispatchUIEvent(config, element, 'change') - } - - config.keyboardState.carryValue = textToBeTyped - }, - }, - { - matches: (keyDef, element) => - keyDef.key?.length === 1 && - isElementType(element, 'input', {type: 'number', readOnly: false}), - handle: (keyDef, element, config) => { - if (!/[\d.\-e]/.test(keyDef.key as string)) { - return - } - - const {getNewValue, commit} = prepareInput( - config, - 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' - // it allows one '.' at any place before e - const valueParts = newValue.split('e', 2) - if ( - Number(newValue.match(/-/g)?.length) > 2 || - Number(newValue.match(/\./g)?.length) > 1 || - (valueParts[1] && !/^-?\d*$/.test(valueParts[1])) - ) { - return - } - - commit() - }, - }, - { - matches: (keyDef, element) => - keyDef.key?.length === 1 && - (isEditableInput(element) || - isElementType(element, 'textarea', {readOnly: false}) || - isContentEditable(element)) && - getSpaceUntilMaxLength(element) !== 0, - handle: (keyDef, element, config) => { - prepareInput(config, keyDef.key as string, element)?.commit() - }, - }, - { - matches: (keyDef, element) => - keyDef.key === 'Enter' && - (isElementType(element, 'textarea', {readOnly: false}) || - isContentEditable(element)) && - getSpaceUntilMaxLength(element) !== 0, - handle: (keyDef, element, config) => { - prepareInput( - config, - '\n', - element, - isContentEditable(element) && !config.keyboardState.modifiers.Shift - ? 'insertParagraph' - : 'insertLineBreak', - )?.commit() - }, - }, -] diff --git a/src/keyboard/plugins/combination.ts b/src/keyboard/plugins/combination.ts deleted file mode 100644 index 28492e2d..00000000 --- a/src/keyboard/plugins/combination.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Default behavior for key combinations - */ - -import {behaviorPlugin} from '../types' -import {selectAll} from '../../utils' - -export const keydownBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element, {keyboardState}) => - keyDef.code === 'KeyA' && keyboardState.modifiers.Control, - handle: (keyDef, element) => selectAll(element), - }, -] diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts deleted file mode 100644 index 60b67ad5..00000000 --- a/src/keyboard/plugins/control.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * This file should contain behavior for arrow keys as described here: - * https://w3c.github.io/uievents-code/#key-controlpad-section - */ - -import {behaviorPlugin} from '../types' -import { - getValue, - isContentEditable, - isEditable, - isElementType, - prepareInput, - setSelectionRange, -} from '../../utils' - -export const keydownBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element) => - (keyDef.key === 'Home' || keyDef.key === 'End') && - (isElementType(element, ['input', 'textarea']) || - isContentEditable(element)), - handle: (keyDef, element) => { - // This could probably been improved by collapsing a selection range - if (keyDef.key === 'Home') { - setSelectionRange(element, 0, 0) - } else { - const newPos = getValue(element)?.length ?? /* istanbul ignore next */ 0 - setSelectionRange(element, newPos, newPos) - } - }, - }, - { - matches: (keyDef, element) => - (keyDef.key === 'PageUp' || keyDef.key === 'PageDown') && - isElementType(element, ['input']), - handle: (keyDef, element) => { - // This could probably been improved by collapsing a selection range - if (keyDef.key === 'PageUp') { - setSelectionRange(element, 0, 0) - } else { - const newPos = getValue(element)?.length ?? /* istanbul ignore next */ 0 - setSelectionRange(element, newPos, newPos) - } - }, - }, - { - matches: (keyDef, element) => - keyDef.key === 'Delete' && isEditable(element), - handle: (keDef, element, config) => { - prepareInput(config, '', element, 'deleteContentForward')?.commit() - }, - }, -] diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts deleted file mode 100644 index 415ce4f8..00000000 --- a/src/keyboard/plugins/functional.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * This file should contain behavior for functional keys as described here: - * https://w3c.github.io/uievents-code/#key-alphanumeric-functional - */ - -import {setUISelection} from '../../document' -import {dispatchUIEvent} from '../../event' -import { - blur, - focus, - getTabDestination, - hasFormSubmit, - isClickableInput, - isEditable, - isElementType, - prepareInput, -} from '../../utils' -import {behaviorPlugin} from '../types' - -export const keydownBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element) => - keyDef.key === 'Backspace' && isEditable(element), - handle: (keyDef, element, config) => { - prepareInput(config, '', element, 'deleteContentBackward')?.commit() - }, - }, - { - matches: keyDef => keyDef.key === 'Tab', - handle: (keyDef, element, {keyboardState}) => { - const dest = getTabDestination(element, keyboardState.modifiers.Shift) - if (dest === element.ownerDocument.body) { - blur(element) - } else { - focus(dest) - if (isElementType(dest, ['input', 'textarea'])) { - setUISelection(dest, { - anchorOffset: 0, - focusOffset: dest.value.length, - }) - } - } - }, - }, -] - -export const keypressBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element) => - keyDef.key === 'Enter' && - isElementType(element, 'input') && - ['checkbox', 'radio'].includes(element.type), - handle: (keyDef, element, config) => { - const form = (element as HTMLInputElement).form - - if (hasFormSubmit(form)) { - dispatchUIEvent(config, form, 'submit') - } - }, - }, - { - matches: (keyDef, element) => - keyDef.key === 'Enter' && - (isClickableInput(element) || - // Links with href defined should handle Enter the same as a click - (isElementType(element, 'a') && Boolean(element.href))), - handle: (keyDef, element, config) => { - dispatchUIEvent(config, element, 'click') - }, - }, - { - matches: (keyDef, element) => - keyDef.key === 'Enter' && isElementType(element, 'input'), - handle: (keyDef, element, config) => { - const form = (element as HTMLInputElement).form - - if ( - form && - (form.querySelectorAll('input').length === 1 || hasFormSubmit(form)) - ) { - dispatchUIEvent(config, form, 'submit') - } - }, - }, -] - -export const keyupBehavior: behaviorPlugin[] = [ - { - matches: (keyDef, element) => - keyDef.key === ' ' && isClickableInput(element), - handle: (keyDef, element, config) => { - dispatchUIEvent(config, element, 'click') - }, - }, -] diff --git a/src/keyboard/plugins/index.ts b/src/keyboard/plugins/index.ts deleted file mode 100644 index 46fdcfc4..00000000 --- a/src/keyboard/plugins/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {behaviorPlugin} from '../types' -import * as arrowKeys from './arrow' -import * as controlKeys from './control' -import * as characterKeys from './character' -import * as functionalKeys from './functional' -import * as combination from './combination' -import * as modifiers from './modifiers' - -export const preKeydownBehavior: behaviorPlugin[] = [ - ...modifiers.preKeydownBehavior, -] - -export const keydownBehavior: behaviorPlugin[] = [ - ...arrowKeys.keydownBehavior, - ...controlKeys.keydownBehavior, - ...functionalKeys.keydownBehavior, - ...combination.keydownBehavior, -] - -export const keypressBehavior: behaviorPlugin[] = [ - ...functionalKeys.keypressBehavior, - ...characterKeys.keypressBehavior, -] - -export const preKeyupBehavior: behaviorPlugin[] = [ - ...modifiers.preKeyupBehavior, -] - -export const keyupBehavior: behaviorPlugin[] = [...functionalKeys.keyupBehavior] - -export const postKeyupBehavior: behaviorPlugin[] = [ - ...modifiers.postKeyupBehavior, -] diff --git a/src/keyboard/plugins/modifiers.ts b/src/keyboard/plugins/modifiers.ts deleted file mode 100644 index 2c948b3f..00000000 --- a/src/keyboard/plugins/modifiers.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * This file should contain behavior for modifier keys: - * https://www.w3.org/TR/uievents-key/#keys-modifier - */ - -import {dispatchUIEvent} from '../../event' -import {getKeyEventProps} from '../../utils' -import {behaviorPlugin} from '../types' - -const modifierKeys = [ - 'Alt', - 'AltGraph', - 'Control', - 'Fn', - 'Meta', - 'Shift', - 'Symbol', -] as const -type ModififierKey = typeof modifierKeys[number] - -const modifierLocks = [ - 'CapsLock', - 'FnLock', - 'NumLock', - 'ScrollLock', - 'SymbolLock', -] as const -type ModififierLockKey = typeof modifierLocks[number] - -// modifierKeys switch on the modifier BEFORE the keydown event -export const preKeydownBehavior: behaviorPlugin[] = [ - { - matches: keyDef => modifierKeys.includes(keyDef.key as ModififierKey), - handle: (keyDef, element, config) => { - config.keyboardState.modifiers[keyDef.key as ModififierKey] = true - - // AltGraph produces an extra keydown for Control - // The modifier does not change - if (keyDef.key === 'AltGraph') { - const ctrlKeyDef = config.keyboardMap.find( - k => k.key === 'Control', - ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - dispatchUIEvent( - config, - element, - 'keydown', - getKeyEventProps(ctrlKeyDef), - ) - } - }, - }, - { - matches: keyDef => modifierLocks.includes(keyDef.key as ModififierLockKey), - handle: (keyDef, element, {keyboardState}) => { - const key = keyDef.key as ModififierLockKey - keyboardState.modifierPhase[key] = keyboardState.modifiers[key] - - if (!keyboardState.modifierPhase[key]) { - keyboardState.modifiers[key] = true - } - }, - }, -] - -// modifierKeys switch off the modifier BEFORE the keyup event -export const preKeyupBehavior: behaviorPlugin[] = [ - { - matches: keyDef => modifierKeys.includes(keyDef.key as ModififierKey), - handle: (keyDef, element, {keyboardState}) => { - keyboardState.modifiers[keyDef.key as ModififierKey] = false - }, - }, - { - matches: keyDef => modifierLocks.includes(keyDef.key as ModififierLockKey), - handle: (keyDef, element, {keyboardState}) => { - const key = keyDef.key as ModififierLockKey - - if (keyboardState.modifierPhase[key]) { - keyboardState.modifiers[key] = false - } - }, - }, -] - -export const postKeyupBehavior: behaviorPlugin[] = [ - // AltGraph produces an extra keyup for Control - // The modifier does not change - { - matches: keyDef => keyDef.key === 'AltGraph', - handle: (keyDef, element, config) => { - const ctrlKeyDef = config.keyboardMap.find( - k => k.key === 'Control', - ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - dispatchUIEvent(config, element, 'keyup', getKeyEventProps(ctrlKeyDef)) - }, - }, -] diff --git a/src/keyboard/shared/fireChangeForInputTimeIfValid.ts b/src/keyboard/shared/fireChangeForInputTimeIfValid.ts deleted file mode 100644 index 9c59a096..00000000 --- a/src/keyboard/shared/fireChangeForInputTimeIfValid.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {setUIValue} from '../../document' -import {dispatchUIEvent} from '../../event' -import {Config} from '../../setup' -import {isValidInputTimeValue} from '../../utils' - -export function fireChangeForInputTimeIfValid( - config: Config, - el: HTMLInputElement & {type: 'time'}, - prevValue: unknown, - timeNewEntry: string, -) { - if (isValidInputTimeValue(el, timeNewEntry) && prevValue !== timeNewEntry) { - setUIValue(el, timeNewEntry) - dispatchUIEvent(config, el, 'change') - } -} diff --git a/src/keyboard/shared/index.ts b/src/keyboard/shared/index.ts deleted file mode 100644 index 3773daa9..00000000 --- a/src/keyboard/shared/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './fireChangeForInputTimeIfValid' diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts index 3f2a5cd5..9c3bfe24 100644 --- a/src/keyboard/types.ts +++ b/src/keyboard/types.ts @@ -1,5 +1,3 @@ -import {Config} from '../setup' - /** * @internal Do not create/alter this by yourself as this type might be subject to changes. */ @@ -76,8 +74,3 @@ export interface keyboardKey { /** Does the character in `key` require/imply a shiftKey to be pressed? */ shift?: boolean } - -export interface behaviorPlugin { - matches: (keyDef: keyboardKey, element: Element, config: Config) => boolean - handle: (keyDef: keyboardKey, element: Element, config: Config) => void -} diff --git a/src/utility/clear.ts b/src/utility/clear.ts index de3030f9..7cf4daf2 100644 --- a/src/utility/clear.ts +++ b/src/utility/clear.ts @@ -1,10 +1,10 @@ import {Config, Instance} from '../setup' import { focus, + input, isAllSelected, isDisabled, isEditable, - prepareInput, selectAll, } from '../utils' @@ -25,5 +25,5 @@ export async function clear(this: Instance, element: Element) { throw new Error('The element content to be cleared could not be selected.') } - prepareInput(this[Config], '', element, 'deleteContentBackward')?.commit() + input(this[Config], element, '', 'deleteContentBackward') } diff --git a/src/utils/edit/calculateNewValue.ts b/src/utils/edit/calculateNewValue.ts deleted file mode 100644 index 176a6f60..00000000 --- a/src/utils/edit/calculateNewValue.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {getUIValue} from '../../document' -import {EditableInputType} from './isEditable' -import {isValidDateValue} from './isValidDateValue' -import {isValidInputTimeValue} from './isValidInputTimeValue' - -/** - * Calculate a new text value. - */ -export function calculateNewValue( - inputData: string, - node: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement, - { - startOffset, - endOffset, - }: { - startOffset: number - endOffset: number - }, - inputType?: string, -) { - const value = getUIValue(node) - - const prologEnd = Math.max( - 0, - startOffset === endOffset && inputType === 'deleteContentBackward' - ? startOffset - 1 - : startOffset, - ) - const prolog = value.substring(0, prologEnd) - const epilogStart = Math.min( - value.length, - startOffset === endOffset && inputType === 'deleteContentForward' - ? startOffset + 1 - : endOffset, - ) - const epilog = value.substring(epilogStart, value.length) - - let newValue = `${prolog}${inputData}${epilog}` - const newOffset = prologEnd + inputData.length - - if ( - (node as HTMLInputElement).type === 'date' && - !isValidDateValue(node as HTMLInputElement & {type: 'date'}, newValue) - ) { - newValue = value - } - - if ( - (node as HTMLInputElement).type === 'time' && - !isValidInputTimeValue(node as HTMLInputElement & {type: 'time'}, newValue) - ) { - if ( - isValidInputTimeValue( - node as HTMLInputElement & {type: 'time'}, - inputData, - ) - ) { - newValue = inputData - } else { - newValue = value - } - } - - return { - oldValue: value, - newValue, - newOffset, - } -} diff --git a/src/utils/edit/editInputElement.ts b/src/utils/edit/editInputElement.ts deleted file mode 100644 index b26c2f07..00000000 --- a/src/utils/edit/editInputElement.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {setUIValue, startTrackValue, endTrackValue} from '../../document' -import {dispatchUIEvent} from '../../event' -import {Config} from '../../setup' -import {setSelection} from '../focus/selection' - -/** - * 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( - config: Config, - element: HTMLInputElement | HTMLTextAreaElement, - { - newValue, - newSelection, - eventOverrides, - }: { - newValue: string - newSelection: { - node: Node - offset: number - } - eventOverrides: InputEventInit - }, -) { - const oldValue = element.value - - // apply the changes before firing the input event, so that input handlers can access the altered dom and selection - setUIValue(element, newValue) - setSelection({ - focusNode: newSelection.node, - anchorOffset: newSelection.offset, - focusOffset: newSelection.offset, - }) - - // When the input event happens in the browser, React executes all event handlers - // and if they change state of a controlled value, nothing happens. - // But when we trigger the event handlers in test environment, - // the changes are rolled back by React before the state update is applied. - // Then the updated state is applied which results in a resetted cursor. - // There is probably a better way to work around if we figure out - // why the batched update is executed differently in our test environment. - startTrackValue(element as HTMLInputElement) - - dispatchUIEvent(config, element, 'input', eventOverrides) - - const tracked = endTrackValue(element as HTMLInputElement) - if ( - tracked?.length === 2 && - tracked[0] === oldValue && - tracked[1] === newValue - ) { - setSelection({ - focusNode: newSelection.node, - anchorOffset: newSelection.offset, - focusOffset: newSelection.offset, - }) - } -} diff --git a/src/utils/edit/input.ts b/src/utils/edit/input.ts new file mode 100644 index 00000000..f7202863 --- /dev/null +++ b/src/utils/edit/input.ts @@ -0,0 +1,272 @@ +import { + endTrackValue, + getUIValue, + setUIValue, + startTrackValue, + UISelectionRange, +} from '../../document' +import {dispatchUIEvent} from '../../event' +import {Config} from '../../setup' +import { + getInputRange, + getNextCursorPosition, + isElementType, + setSelection, +} from '../../utils' +import {buildTimeValue} from './buildTimeValue' +import {editableInputTypes} from './isEditable' +import {isValidDateOrTimeValue} from './isValidDateOrTimeValue' +import {getSpaceUntilMaxLength} from './maxLength' + +type EditableInputOrTextarea = + | (HTMLInputElement & {type: editableInputTypes}) + | HTMLTextAreaElement +type DateOrTimeInput = HTMLInputElement & {type: 'date' | 'time'} + +function isDateOrTime(element: Element): element is DateOrTimeInput { + return ( + isElementType(element, 'input') && ['date', 'time'].includes(element.type) + ) +} + +export function input( + config: Config, + element: Element, + data: string, + inputType: string = 'insertText', +) { + const inputRange = getInputRange(element) + + /* istanbul ignore if */ + if (!inputRange) { + return + } + + // There is no `beforeinput` event on `date` and `time` input + if (!isDateOrTime(element)) { + const unprevented = dispatchUIEvent(config, element, 'beforeinput', { + inputType, + data, + }) + + if (!unprevented) { + return + } + } + + if ('startContainer' in inputRange) { + editContenteditable(config, element, inputRange, data, inputType) + } else { + editInputElement( + config, + element as EditableInputOrTextarea, + inputRange, + data, + inputType, + ) + } +} + +function editContenteditable( + config: Config, + element: Element, + inputRange: Range, + data: string, + inputType: string, +) { + let del: boolean = false + + if (!inputRange.collapsed) { + del = true + inputRange.deleteContents() + } else if ( + ['deleteContentBackward', 'deleteContentForward'].includes(inputType) + ) { + const nextPosition = getNextCursorPosition( + inputRange.startContainer, + inputRange.startOffset, + inputType === 'deleteContentBackward' ? -1 : 1, + inputType, + ) + if (nextPosition) { + del = true + const delRange = inputRange.cloneRange() + if (delRange.comparePoint(nextPosition.node, nextPosition.offset) < 0) { + delRange.setStart(nextPosition.node, nextPosition.offset) + } else { + delRange.setEnd(nextPosition.node, nextPosition.offset) + } + delRange.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) + } + } + + if (del || data) { + dispatchUIEvent(config, element, 'input', {inputType}) + } +} + +function editInputElement( + config: Config, + element: EditableInputOrTextarea, + inputRange: UISelectionRange, + data: string, + inputType: string, +) { + let dataToInsert = data + const spaceUntilMaxLength = getSpaceUntilMaxLength(element) + if (spaceUntilMaxLength !== undefined) { + if (spaceUntilMaxLength > 0) { + dataToInsert = data.substring(0, spaceUntilMaxLength) + } else { + return + } + } + + const {newValue, newOffset, oldValue} = calculateNewValue( + dataToInsert, + element, + inputRange, + inputType, + ) + + if ( + newValue === oldValue && + newOffset === inputRange.startOffset && + newOffset === inputRange.endOffset + ) { + return + } + + if ( + isElementType(element, 'input', {type: 'number'}) && + !isValidNumberInput(newValue) + ) { + return + } + + setUIValue(element, newValue) + setSelection({ + focusNode: element, + anchorOffset: newOffset, + focusOffset: newOffset, + }) + + if (isDateOrTime(element)) { + if (isValidDateOrTimeValue(element, newValue)) { + commitInput(config, element, oldValue, newValue, newOffset, {}) + dispatchUIEvent(config, element, 'change') + } + } else { + commitInput(config, element, oldValue, newValue, newOffset, { + data, + inputType, + }) + } +} + +function calculateNewValue( + inputData: string, + node: EditableInputOrTextarea, + { + startOffset, + endOffset, + }: { + startOffset: number + endOffset: number + }, + inputType?: string, +) { + const value = getUIValue(node) + + const prologEnd = Math.max( + 0, + startOffset === endOffset && inputType === 'deleteContentBackward' + ? startOffset - 1 + : startOffset, + ) + const prolog = value.substring(0, prologEnd) + const epilogStart = Math.min( + value.length, + startOffset === endOffset && inputType === 'deleteContentForward' + ? startOffset + 1 + : endOffset, + ) + const epilog = value.substring(epilogStart, value.length) + + let newValue = `${prolog}${inputData}${epilog}` + let newOffset = prologEnd + inputData.length + + if (isElementType(node, 'input', {type: 'time'} as const)) { + const builtValue = buildTimeValue(newValue) + if (builtValue !== '' && isValidDateOrTimeValue(node, builtValue)) { + newValue = builtValue + newOffset = builtValue.length + } + } + + return { + oldValue: value, + newValue, + newOffset, + } +} + +function commitInput( + config: Config, + element: EditableInputOrTextarea, + oldValue: string, + newValue: string, + newOffset: number, + inputInit: InputEventInit, +) { + // When the input event happens in the browser, React executes all event handlers + // and if they change state of a controlled value, nothing happens. + // But when we trigger the event handlers in test environment, + // the changes are rolled back by React before the state update is applied. + // Then the updated state is applied which results in a resetted cursor. + // There is probably a better way to work around if we figure out + // why the batched update is executed differently in our test environment. + startTrackValue(element) + + dispatchUIEvent(config, element, 'input', inputInit) + + const tracked = endTrackValue(element as HTMLInputElement) + if ( + tracked?.length === 2 && + tracked[0] === oldValue && + tracked[1] === newValue + ) { + setSelection({ + focusNode: element, + anchorOffset: newOffset, + focusOffset: newOffset, + }) + } +} + +function isValidNumberInput(value: 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' + // it allows one '.' at any place before e + const valueParts = value.split('e', 2) + return !( + /[^\d.\-e]/.test(value) || + Number(value.match(/-/g)?.length) > 2 || + Number(value.match(/\./g)?.length) > 1 || + (valueParts[1] && !/^-?\d*$/.test(valueParts[1])) + ) +} diff --git a/src/utils/edit/isValidDateValue.ts b/src/utils/edit/isValidDateOrTimeValue.ts similarity index 56% rename from src/utils/edit/isValidDateValue.ts rename to src/utils/edit/isValidDateOrTimeValue.ts index 22135b65..06c46eb1 100644 --- a/src/utils/edit/isValidDateValue.ts +++ b/src/utils/edit/isValidDateOrTimeValue.ts @@ -1,7 +1,7 @@ -export function isValidDateValue( - element: HTMLInputElement & {type: 'date'}, +export function isValidDateOrTimeValue( + element: HTMLInputElement & {type: 'date' | 'time'}, value: string, -): boolean { +) { const clone = element.cloneNode() as HTMLInputElement clone.value = value return clone.value === value diff --git a/src/utils/edit/isValidInputTimeValue.ts b/src/utils/edit/isValidInputTimeValue.ts deleted file mode 100644 index 1e96146f..00000000 --- a/src/utils/edit/isValidInputTimeValue.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function isValidInputTimeValue( - element: HTMLInputElement & {type: 'time'}, - timeValue: string, -): boolean { - const clone = element.cloneNode() as HTMLInputElement - clone.value = timeValue - return clone.value === timeValue -} diff --git a/src/utils/edit/prepareInput.ts b/src/utils/edit/prepareInput.ts deleted file mode 100644 index 6a6496a5..00000000 --- a/src/utils/edit/prepareInput.ts +++ /dev/null @@ -1,107 +0,0 @@ -import {dispatchUIEvent} from '../../event' -import {Config} from '../../setup' -import {calculateNewValue, editInputElement, getInputRange} from '../../utils' -import {getNextCursorPosition} from '../focus/cursor' - -export function prepareInput( - config: Config, - data: string, - element: Element, - inputType: string = 'insertText', -) { - const inputRange = getInputRange(element) - - /* istanbul ignore if */ - if (!inputRange) { - return - } - - if ('startContainer' in inputRange) { - return { - commit: () => { - let del: boolean = false - - if (!inputRange.collapsed) { - del = true - inputRange.deleteContents() - } else if ( - ['deleteContentBackward', 'deleteContentForward'].includes(inputType) - ) { - const nextPosition = getNextCursorPosition( - inputRange.startContainer, - inputRange.startOffset, - inputType === 'deleteContentBackward' ? -1 : 1, - inputType, - ) - if (nextPosition) { - del = true - const delRange = inputRange.cloneRange() - if ( - delRange.comparePoint(nextPosition.node, nextPosition.offset) < 0 - ) { - delRange.setStart(nextPosition.node, nextPosition.offset) - } else { - delRange.setEnd(nextPosition.node, nextPosition.offset) - } - delRange.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) - } - } - - if (del || data) { - dispatchUIEvent(config, element, 'input', {inputType}) - } - }, - } - } else { - return { - getNewValue: () => - calculateNewValue( - data, - element as HTMLTextAreaElement, - inputRange, - inputType, - ).newValue, - commit: () => { - const {newValue, newOffset, oldValue} = calculateNewValue( - data, - element as HTMLTextAreaElement, - inputRange, - inputType, - ) - - if ( - newValue === oldValue && - newOffset === inputRange.startOffset && - newOffset === inputRange.endOffset - ) { - return - } - - editInputElement(config, element as HTMLTextAreaElement, { - newValue, - newSelection: { - node: element, - offset: newOffset, - }, - eventOverrides: { - inputType, - }, - }) - }, - } - } -} diff --git a/src/utils/focus/selection.ts b/src/utils/focus/selection.ts index a60bc716..c09282c2 100644 --- a/src/utils/focus/selection.ts +++ b/src/utils/focus/selection.ts @@ -2,6 +2,7 @@ import {isElementType} from '../misc/isElementType' import {getUISelection, setUISelection, UISelectionRange} from '../../document' import {editableInputTypes} from '../edit/isEditable' import {isContentEditable, getContentEditable} from '../edit/isContentEditable' +import {getNextCursorPosition} from './cursor' /** * Backward-compatible selection. @@ -194,3 +195,47 @@ export function setSelection({ ?.getSelection() ?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) } + +/** + * Move the selection + */ +export function moveSelection(node: Element, direction: -1 | 1) { + // TODO: implement shift + + if (hasOwnSelection(node)) { + const selection = getUISelection(node) + + setSelection({ + focusNode: node, + focusOffset: + selection.startOffset === selection.endOffset + ? selection.focusOffset + direction + : direction < 0 + ? selection.startOffset + : selection.endOffset, + }) + } else { + const selection = node.ownerDocument.getSelection() + + /* istanbul ignore if */ + if (!selection) { + return + } + + if (selection.isCollapsed) { + const nextPosition = getNextCursorPosition( + selection.focusNode as Node, + selection.focusOffset, + direction, + ) + if (nextPosition) { + setSelection({ + focusNode: nextPosition.node, + focusOffset: nextPosition.offset, + }) + } + } else { + selection[direction < 0 ? 'collapseToStart' : 'collapseToEnd']() + } + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4f27529e..abff1632 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,16 +5,10 @@ export * from './dataTransfer/DataTransfer' export * from './dataTransfer/FileList' export * from './dataTransfer/Clipboard' -export * from './edit/buildTimeValue' -export * from './edit/calculateNewValue' -export * from './edit/editInputElement' export * from './edit/getValue' +export * from './edit/input' export * from './edit/isContentEditable' export * from './edit/isEditable' -export * from './edit/isValidDateValue' -export * from './edit/isValidInputTimeValue' -export * from './edit/maxLength' -export * from './edit/prepareInput' export * from './edit/setFiles' export * from './focus/blur' @@ -43,7 +37,6 @@ export * from './misc/isVisible' export * from './misc/isDisabled' export * from './misc/level' export * from './misc/wait' -export * from './misc/hasFormSubmit' export * from './pointer/cssPointerEvents' export * from './pointer/mouseButtons' diff --git a/src/utils/misc/hasFormSubmit.ts b/src/utils/misc/hasFormSubmit.ts deleted file mode 100644 index b8d456cb..00000000 --- a/src/utils/misc/hasFormSubmit.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const hasFormSubmit = ( - form: HTMLFormElement | null, -): form is HTMLFormElement => - !!form?.querySelector( - 'input[type="submit"], button:not([type]), button[type="submit"]', - ) diff --git a/tests/_helpers/dom-event-map.d.ts b/tests/_helpers/dom-event-map.d.ts deleted file mode 100644 index ed4007a3..00000000 --- a/tests/_helpers/dom-event-map.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module '@testing-library/dom/dist/event-map' { - export const eventMap: Record< - string, - { - EventType: string - defaultInit: EventInit - } - > -} diff --git a/tests/_helpers/dom-events.d.ts b/tests/_helpers/dom-events.d.ts new file mode 100644 index 00000000..18d4fbd1 --- /dev/null +++ b/tests/_helpers/dom-events.d.ts @@ -0,0 +1,9 @@ +declare module '@testing-library/dom/dist/event-map.js' { + import {EventType} from '@testing-library/dom' + export const eventMap: { + [k in EventType]: { + EventType: string + defaultInit: EventInit + } + } +} diff --git a/tests/_helpers/utils.ts b/tests/_helpers/utils.ts index 14331359..aa156f19 100644 --- a/tests/_helpers/utils.ts +++ b/tests/_helpers/utils.ts @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ -import {eventMap} from '@testing-library/dom/dist/event-map' import {TestData, TestDataProps} from './trackProps' +import {eventMap} from '#src/event/eventMap' import {isElementType, MouseButton} from '#src/utils' // this is pretty helpful: diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index d9688418..670b6fa2 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -15,6 +15,7 @@ test('should paste text in input', async () => { input[value=""] - focus input[value=""] - focusin input[value=""] - paste + input[value=""] - beforeinput input[value="Hello, world!"] - input `) }) @@ -33,6 +34,7 @@ test('should paste text in textarea', async () => { textarea[value=""] - focus textarea[value=""] - focusin textarea[value=""] - paste + textarea[value=""] - beforeinput textarea[value="Hello, world!"] - input `) }) diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts index 7f81725c..6799cddb 100644 --- a/tests/dom/customElement.ts +++ b/tests/dom/customElement.ts @@ -69,14 +69,17 @@ test('types text inside custom element', async () => { input[value=""] - click: primary input[value=""] - keydown: S input[value=""] - keypress: S + input[value=""] - beforeinput input[value="S"] - input input[value="S"] - keyup: S input[value="S"] - keydown: u input[value="S"] - keypress: u + input[value="S"] - beforeinput input[value="Su"] - input input[value="Su"] - keyup: u input[value="Su"] - keydown: p input[value="Su"] - keypress: p + input[value="Su"] - beforeinput input[value="Sup"] - input input[value="Sup"] - keyup: p `) diff --git a/tests/event/behavior/keydown.ts b/tests/event/behavior/keydown.ts new file mode 100644 index 00000000..cd24690e --- /dev/null +++ b/tests/event/behavior/keydown.ts @@ -0,0 +1,17 @@ +import userEvent from '#src' +import {setup} from '#testHelpers/utils' + +test.each(['Backspace', 'Delete', 'End', 'Home', 'PageUp', 'PageDown'])( + 'implement no keydown behavior for [%s] outside of editable context', + async code => { + const {element, getEvents, clearEventCalls} = setup( + `
`, + ) + element.focus() + clearEventCalls() + + await userEvent.keyboard(`[${code}>]`) + + expect(getEvents().map(e => e.type)).toEqual(['keydown']) + }, +) diff --git a/tests/event/behavior/keyup.ts b/tests/event/behavior/keyup.ts new file mode 100644 index 00000000..2658b39e --- /dev/null +++ b/tests/event/behavior/keyup.ts @@ -0,0 +1,20 @@ +import userEvent from '#src' +import {setup} from '#testHelpers/utils' + +describe('release [Space]', () => { + test.each([ + [``, true], + [``, true], + [``, false], + ])('dispatch `click` on `%s`: %s', async (html, click) => { + const {element, clearEventCalls, eventWasFired} = setup(html) + element.focus() + const user = userEvent.setup() + await user.keyboard('[Space>]') + clearEventCalls() + + await user.keyboard('[/Space]') + + expect(eventWasFired('click')).toBe(click) + }) +}) diff --git a/tests/keyboard/plugin/arrow.ts b/tests/keyboard/arrow.ts similarity index 100% rename from tests/keyboard/plugin/arrow.ts rename to tests/keyboard/arrow.ts diff --git a/tests/keyboard/plugin/character.ts b/tests/keyboard/character.ts similarity index 100% rename from tests/keyboard/plugin/character.ts rename to tests/keyboard/character.ts diff --git a/tests/keyboard/plugin/combination.ts b/tests/keyboard/combination.ts similarity index 100% rename from tests/keyboard/plugin/combination.ts rename to tests/keyboard/combination.ts diff --git a/tests/keyboard/plugin/control.ts b/tests/keyboard/control.ts similarity index 100% rename from tests/keyboard/plugin/control.ts rename to tests/keyboard/control.ts diff --git a/tests/keyboard/plugin/functional.ts b/tests/keyboard/functional.ts similarity index 96% rename from tests/keyboard/plugin/functional.ts rename to tests/keyboard/functional.ts index 049828be..cad907d1 100644 --- a/tests/keyboard/plugin/functional.ts +++ b/tests/keyboard/functional.ts @@ -83,50 +83,59 @@ test.each([ cases( 'submit form on [Enter]', - async ({html, submit}) => { + async ({html, click, submit}) => { const {element, getEvents} = setup(html) ;(element.children[1] as HTMLInputElement).focus() await userEvent.keyboard('[Enter]') - expect(getEvents('click')).toHaveLength(0) + expect(getEvents('click')).toHaveLength(click ? 1 : 0) expect(getEvents('submit')).toHaveLength(submit ? 1 : 0) }, { 'with ``': { html: `
`, + click: true, submit: true, }, 'with `
`, ) @@ -116,14 +115,14 @@ cases( range[1], ) - prepareInput(createConfig(), input, element, inputType)?.commit() + input(createConfig(), element, data, inputType) expect(element.innerHTML).toBe(html) }, { insertText: { range: [1, 3], - input: 'XYZ', + data: 'XYZ', html: 'XYZ', }, 'deleteContentBackward extended': { @@ -159,3 +158,21 @@ cases( // }, }, ) + +test('prevent input on `beforeinput` event', () => { + const {element, eventWasFired} = setup(``) + element.addEventListener( + 'beforeinput', + e => e.data === 'a' && e.preventDefault(), + ) + + input(createConfig(), element, 'a') + + expect(eventWasFired('beforeinput')).toBe(true) + expect(eventWasFired('input')).toBe(false) + expect(element).toHaveValue('') + + input(createConfig(), element, 'b') + expect(eventWasFired('input')).toBe(true) + expect(element).toHaveValue('b') +}) From bb5ca7978311d3a1b746e278421fddec75b029f2 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 10 Feb 2022 20:17:52 +0100 Subject: [PATCH 57/84] refactor: refactor test helpers (#852) --- jest.config.js | 2 +- src/event/createEvent.ts | 8 +- src/event/eventMap.ts | 4 + tests/_helpers/index.ts | 15 + tests/_helpers/listeners.ts | 304 ++++++++++++++++ tests/_helpers/setup.ts | 33 ++ tests/_helpers/utils.ts | 443 ------------------------ tests/clipboard/copy.ts | 2 +- tests/clipboard/cut.ts | 2 +- tests/clipboard/paste.ts | 2 +- tests/convenience/click.ts | 2 +- tests/convenience/hover.ts | 2 +- tests/convenience/tab.ts | 2 +- tests/document/index.ts | 2 +- tests/dom/customElement.ts | 2 +- tests/event/behavior/keydown.ts | 2 +- tests/event/behavior/keyup.ts | 2 +- tests/event/dispatchEvent.ts | 2 +- tests/keyboard/arrow.ts | 2 +- tests/keyboard/character.ts | 2 +- tests/keyboard/combination.ts | 2 +- tests/keyboard/control.ts | 2 +- tests/keyboard/functional.ts | 8 +- tests/keyboard/index.ts | 2 +- tests/keyboard/keyboardAction.ts | 2 +- tests/keyboard/modifiers.ts | 2 +- tests/pointer/click.ts | 2 +- tests/pointer/drag.ts | 2 +- tests/pointer/index.ts | 2 +- tests/pointer/move.ts | 2 +- tests/pointer/select.ts | 2 +- tests/react/type.tsx | 2 +- tests/setup/index.ts | 2 +- tests/tsconfig.json | 2 +- tests/utility/clear.ts | 2 +- tests/utility/selectOptions/_setup.ts | 73 ++++ tests/utility/selectOptions/deselect.ts | 3 +- tests/utility/selectOptions/select.ts | 8 +- tests/utility/type.ts | 2 +- tests/utility/upload.ts | 2 +- tests/utils/edit/calculateNewValue.ts | 2 +- tests/utils/edit/input.ts | 2 +- tests/utils/edit/isContentEditable.ts | 2 +- tests/utils/edit/setFiles.ts | 2 +- tests/utils/focus/blur.ts | 2 +- tests/utils/focus/cursor.ts | 2 +- tests/utils/focus/focus.ts | 2 +- tests/utils/focus/getTabDestination.ts | 2 +- tests/utils/focus/selectAll.ts | 2 +- tests/utils/focus/selection.ts | 2 +- tests/utils/misc/hasPointerEvents.ts | 2 +- tests/utils/misc/isDescendantOrSelf.ts | 2 +- tests/utils/misc/isElementType.ts | 2 +- tests/utils/misc/isVisible.ts | 2 +- 54 files changed, 483 insertions(+), 504 deletions(-) create mode 100644 tests/_helpers/index.ts create mode 100644 tests/_helpers/listeners.ts create mode 100644 tests/_helpers/setup.ts delete mode 100644 tests/_helpers/utils.ts create mode 100644 tests/utility/selectOptions/_setup.ts diff --git a/jest.config.js b/jest.config.js index a2132f4a..05567dd4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,7 @@ config.roots = [''] config.moduleNameMapper = { '^#src$': '/src/index', '^#src/(.*)$': '/src/$1', - '^#testHelpers/(.*)$': '/tests/_helpers/$1', + '^#testHelpers$': '/tests/_helpers/index', } config.testEnvironment = 'jsdom' diff --git a/src/event/createEvent.ts b/src/event/createEvent.ts index 646d709b..e3f5f41a 100644 --- a/src/event/createEvent.ts +++ b/src/event/createEvent.ts @@ -1,5 +1,5 @@ import {createEvent as createEventBase} from '@testing-library/dom' -import {eventMap} from './eventMap' +import {eventMap, eventMapKeys} from './eventMap' import {isMouseEvent} from './eventTypes' import {EventType, PointerCoords} from './types' @@ -30,15 +30,11 @@ export function createEvent( target: Element, init?: EventTypeInit, ) { - const eventKey = Object.keys(eventMap).find( - k => k.toLowerCase() === type, - ) as keyof typeof eventMap - const event = createEventBase( type, target, init, - eventMap[eventKey], + eventMap[eventMapKeys[type] as keyof typeof eventMap], ) as DocumentEventMap[K] // Can not use instanceof, as MouseEvent might be polyfilled. diff --git a/src/event/eventMap.ts b/src/event/eventMap.ts index 7ac4b062..cd86ccf8 100644 --- a/src/event/eventMap.ts +++ b/src/event/eventMap.ts @@ -8,3 +8,7 @@ export const eventMap = { defaultInit: {bubbles: true, cancelable: true, composed: true}, }, } + +export const eventMapKeys: { + [k in keyof DocumentEventMap]?: keyof typeof eventMap +} = Object.fromEntries(Object.keys(eventMap).map(k => [k.toLowerCase(), k])) diff --git a/tests/_helpers/index.ts b/tests/_helpers/index.ts new file mode 100644 index 00000000..701e2847 --- /dev/null +++ b/tests/_helpers/index.ts @@ -0,0 +1,15 @@ +// this is pretty helpful: +// https://codesandbox.io/s/quizzical-worker-eo909 + +expect.addSnapshotSerializer({ + test: (val: unknown) => + Boolean( + typeof val === 'object' + ? Object.prototype.hasOwnProperty.call(val, 'snapshot') + : false, + ), + print: val => String((val)?.snapshot), +}) + +export {setup} from './setup' +export {addEventListener, addListeners} from './listeners' diff --git a/tests/_helpers/listeners.ts b/tests/_helpers/listeners.ts new file mode 100644 index 00000000..a1fb1b1a --- /dev/null +++ b/tests/_helpers/listeners.ts @@ -0,0 +1,304 @@ +import {TestData, TestDataProps} from './trackProps' +import {eventMapKeys} from '#src/event/eventMap' +import {isElementType, MouseButton} from '#src/utils' + +let eventListeners: Array<{ + el: EventTarget + type: string + listener: EventListener +}> = [] + +afterEach(() => { + for (const {el, type, listener} of eventListeners) { + el.removeEventListener(type, listener) + } + eventListeners = [] + document.body.innerHTML = '' +}) + +/** + * Add an event listener that is cleaned up automatically after the test. + */ +export function addEventListener( + el: EventTarget, + type: string, + listener: EventListener, + options?: AddEventListenerOptions, +) { + eventListeners.push({el, type, listener}) + el.addEventListener(type, listener, options) +} + +export type EventHandlers = {[k in keyof DocumentEventMap]?: EventListener} + +/** + * Add listeners for logging events. + */ +export function addListeners( + element: Element & {testData?: TestData}, + { + eventHandlers = {}, + }: { + eventHandlers?: EventHandlers + } = {}, +) { + type CallData = { + event: Event + elementDisplayName: string + testData?: TestData + } + let eventHandlerCalls: CallData[] = [] + + const generalListener = jest.fn(eventHandler).mockName('eventListener') + + for (const eventType of Object.keys(eventMapKeys) as Array< + keyof typeof eventMapKeys + >) { + addEventListener(element, eventType, (...args) => { + generalListener(...args) + eventHandlers[eventType]?.(...args) + }) + } + + addEventListener(element, 'submit', e => e.preventDefault()) + + return { + clearEventCalls, + eventWasFired, + getClickEventsSnapshot, + getEvents, + getEventSnapshot, + } + + function eventHandler(event: Event) { + const target = event.target + const callData: CallData = { + event, + elementDisplayName: + target && isElement(target) ? getElementDisplayName(target) : '', + } + if (element.testData && !element.testData.handled) { + callData.testData = element.testData + // sometimes firing a single event (like click on a checkbox) will + // automatically fire more events (line input and change). + // and we don't want the test data applied to those, so we'll store + // this and not add the testData to our call if that was already handled + element.testData.handled = true + } + eventHandlerCalls.push(callData) + } + + function clearEventCalls() { + generalListener.mockClear() + eventHandlerCalls = [] + } + + function eventWasFired(eventType: keyof GlobalEventHandlersEventMap) { + return getEvents(eventType).length > 0 + } + + function getClickEventsSnapshot() { + const lines = getEvents().map(e => + isMouseEvent(e) + ? `${e.type} - button=${e.button}; buttons=${e.buttons}; detail=${e.detail}` + : isPointerEvent(e) + ? `${e.type} - pointerId=${e.pointerId}; pointerType=${e.pointerType}; isPrimary=${e.isPrimary}` + : e.type, + ) + return {snapshot: lines.join('\n')} + } + + function getEvents( + type?: T, + ): Array { + return generalListener.mock.calls + .map(([e]) => e) + .filter(e => !type || e.type === type) as Array + } + + function getEventSnapshot() { + const eventCalls = eventHandlerCalls + .map(({event, testData, elementDisplayName}) => { + const firstLine = [ + `${elementDisplayName} - ${event.type}`, + [getEventLabel(event), getEventModifiers(event)] + .filter(Boolean) + .join(' '), + ] + .filter(Boolean) + .join(': ') + + return [firstLine, getChanges(testData)].filter(Boolean).join('\n') + }) + .join('\n') + .trim() + + if (eventCalls.length) { + return { + snapshot: [ + `Events fired on: ${getElementDisplayName(element)}`, + eventCalls, + ].join('\n\n'), + } + } else { + return { + snapshot: `No events were fired on: ${getElementDisplayName(element)}`, + } + } + } +} + +function hasProperty( + obj: T, + prop: K, +): obj is T & {[k in K]: unknown} { + return prop in obj +} + +function isElement(target: EventTarget): target is Element { + return 'tagName' in target +} + +function isMouseEvent(event: Event): event is MouseEvent { + return ( + event.constructor.name === 'MouseEvent' || + event.type === 'click' || + event.type.startsWith('mouse') + ) +} + +function isKeyboardEvent(event: Event): event is KeyboardEvent { + return ( + event.constructor.name === 'KeyboardEvent' || event.type.startsWith('key') + ) +} + +function isPointerEvent(event: Event): event is PointerEvent { + return event.type.startsWith('pointer') +} + +function getElementDisplayName(element: Element) { + const displayName = [element.tagName.toLowerCase()] + + if (element.id) { + displayName.push(`#${element.id}`) + } + if (hasProperty(element, 'name') && element.name) { + displayName.push(`[name="${element.name}"]`) + } + if (hasProperty(element, 'htmlFor') && element.htmlFor) { + displayName.push(`[for="${element.htmlFor}"]`) + } + if ( + isElementType(element, 'input') && + ['checkbox', 'radio'].includes(element.type) + ) { + displayName.push(`[checked=${element.checked}]`) + } else if (!isElementType(element, 'button')) { + const v = getElementValue(element) + if (v) { + displayName.push(`[value=${v}]`) + } + } + if (isElementType(element, 'option')) { + displayName.push(`[selected=${element.selected}]`) + } + if (element.getAttribute('role') === 'option') { + displayName.push(`[aria-selected=${element.getAttribute('aria-selected')}]`) + } + + return displayName.join('') +} + +function getElementValue(element: Element) { + if (isElementType(element, 'select') && element.multiple) { + return JSON.stringify(Array.from(element.selectedOptions).map(o => o.value)) + } else if (element.getAttribute('role') === 'listbox') { + return JSON.stringify( + element.querySelector('[aria-selected="true"]')?.innerHTML, + ) + } else if (element.getAttribute('role') === 'option') { + return JSON.stringify(element.innerHTML) + } else if ('value' in element) { + return JSON.stringify((element as HTMLInputElement).value) + } +} + +function getEventLabel(event: Event) { + if ( + isMouseEvent(event) && + [ + 'click', + 'dblclick', + 'mousedown', + 'mouseup', + 'pointerdown', + 'pointerup', + ].includes(event.type) + ) { + return getMouseButtonName(event.button) ?? `button${event.button}` + } else if (isKeyboardEvent(event)) { + return event.key === ' ' ? 'Space' : event.key + } +} + +function getEventModifiers(event: Event) { + return ['altKey', 'shiftKey', 'metaKey', 'ctrlKey'] + .filter(key => event[key as keyof Event]) + .map(k => `{${k.replace('Key', '')}}`) + .join(',') +} + +function getMouseButtonName(button: number) { + return Object.keys(MouseButton).find( + k => MouseButton[k as keyof typeof MouseButton] === button, + ) +} + +function getChanges({before, after}: TestData = {}) { + const changes = new Set() + if (before && after) { + for (const key of Object.keys(before) as Array) { + if (after[key] !== before[key]) { + if (key === 'checked') { + changes.add( + [ + before.checked ? 'checked' : 'unchecked', + after.checked ? 'checked' : 'unchecked', + ].join(' -> '), + ) + } else { + changes.add( + [ + JSON.stringify(getValueWithSelection(before)), + JSON.stringify(getValueWithSelection(after)), + ].join(' -> '), + ) + } + } + } + } + + return Array.from(changes) + .filter(Boolean) + .map(line => ` ${line}`) + .join('\n') +} + +function getValueWithSelection({ + value, + selectionStart, + selectionEnd, +}: TestDataProps = {}) { + return [ + value?.slice(0, selectionStart ?? undefined), + ...(selectionStart === selectionEnd + ? ['{CURSOR}'] + : [ + '{SELECTION}', + value?.slice(selectionStart ?? 0, selectionEnd ?? undefined), + '{/SELECTION}', + ]), + value?.slice(selectionEnd ?? undefined), + ].join('') +} diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts new file mode 100644 index 00000000..eb20711a --- /dev/null +++ b/tests/_helpers/setup.ts @@ -0,0 +1,33 @@ +import {addListeners, EventHandlers} from './listeners' + +export function setup( + ui: string, + { + eventHandlers, + }: { + eventHandlers?: EventHandlers + } = {}, +) { + const div = document.createElement('div') + div.innerHTML = ui.trim() + document.body.append(div) + + type ElementsArray = Elements extends Array ? Elements : [Elements] + // The HTMLCollection in lib.d.ts does not allow array access + type ElementsCollection = HTMLCollection & + ElementsArray & { + item(i: N): ElementsArray[N] + } + + return { + element: div.firstChild as ElementsArray[0], + elements: div.children as ElementsCollection, + // for single elements add the listeners to the element for capturing non-bubbling events + ...addListeners( + div.children.length === 1 ? (div.firstChild as Element) : div, + { + eventHandlers, + }, + ), + } +} diff --git a/tests/_helpers/utils.ts b/tests/_helpers/utils.ts deleted file mode 100644 index aa156f19..00000000 --- a/tests/_helpers/utils.ts +++ /dev/null @@ -1,443 +0,0 @@ -/* eslint-disable testing-library/no-node-access */ -import {TestData, TestDataProps} from './trackProps' -import {eventMap} from '#src/event/eventMap' -import {isElementType, MouseButton} from '#src/utils' - -// this is pretty helpful: -// https://codesandbox.io/s/quizzical-worker-eo909 - -// all of the stuff below is complex magic that makes the simpler tests work -// sorrynotsorry... - -const unstringSnapshotSerializer: jest.SnapshotSerializerPlugin = { - test: (val: unknown) => - Boolean( - typeof val === 'object' - ? Object.prototype.hasOwnProperty.call(val, 'snapshot') - : false, - ), - print: val => String((val)?.snapshot), -} - -expect.addSnapshotSerializer(unstringSnapshotSerializer) - -type EventHandlers = Record - -// The HTMLCollection in lib.d.ts does not allow array access -type HTMLCollection> = Elements & { - item(i: N): Elements[N] -} - -function setup( - ui: string, - { - eventHandlers, - }: { - eventHandlers?: EventHandlers - } = {}, -) { - const div = document.createElement('div') - div.innerHTML = ui.trim() - document.body.append(div) - - type ElementsArray = Elements extends Array ? Elements : [Elements] - return { - element: div.firstChild as ElementsArray[0], - elements: div.children as unknown as HTMLCollection, - // for single elements add the listeners to the element for capturing non-bubbling events - ...addListeners( - div.children.length === 1 ? (div.firstChild as Element) : div, - { - eventHandlers, - }, - ), - } -} - -function setupSelect({ - disabled = false, - disabledOptions = false, - multiple = false, - pointerEvents = 'auto', -} = {}) { - const form = document.createElement('form') - form.innerHTML = ` - - ` - document.body.append(form) - const select = form.querySelector('select') as HTMLSelectElement - const options = Array.from(form.querySelectorAll('option')) - return { - ...addListeners(select), - form, - select, - options, - } -} - -function setupListbox() { - const wrapper = document.createElement('div') - wrapper.innerHTML = ` - -
    -
  • 1
  • -
  • 2
  • -
  • 3
  • -
- ` - document.body.append(wrapper) - const listbox = wrapper.querySelector('[role="listbox"]') as HTMLUListElement - const options = Array.from( - wrapper.querySelectorAll('[role="option"]'), - ) - - // the user is responsible for handling aria-selected on listbox options - options.forEach(el => - el.addEventListener('click', e => { - const target = e.currentTarget as HTMLElement - target.setAttribute( - 'aria-selected', - JSON.stringify( - !JSON.parse(String(target.getAttribute('aria-selected'))), - ), - ) - }), - ) - - return { - ...addListeners(listbox), - listbox, - options, - } -} - -const eventLabelGetters = { - KeyboardEvent(event: KeyboardEvent) { - return [event.key].join(' ').trim() - }, - MouseEvent(event: MouseEvent) { - if ( - [ - 'click', - 'dblclick', - 'mousedown', - 'mouseup', - 'pointerdown', - 'pointerup', - ].includes(event.type) - ) { - const buttonName = Object.keys(MouseButton).find( - k => MouseButton[k as keyof typeof MouseButton] === event.button, - ) - // const buttonName = Object.keys(MouseButton).find(k => MouseButton[k as keyof typeof MouseButton] === event.button) - return buttonName ?? `button${event.button}` - } - }, -} as const - -let eventListeners: Array<{ - el: EventTarget - type: string - listener: EventListener -}> = [] - -// asside from the hijacked listener stuff here, it's also important to call -// this function rather than simply calling addEventListener yourself -// because it adds your listener to an eventListeners array which is cleaned -// up automatically which will help use avoid memory leaks. -function addEventListener( - el: EventTarget, - type: string, - listener: EventListener, - options?: AddEventListenerOptions, -) { - eventListeners.push({el, type, listener}) - el.addEventListener(type, listener, options) -} - -function getElementValue(element: Element) { - if (isElementType(element, 'select') && element.multiple) { - return JSON.stringify(Array.from(element.selectedOptions).map(o => o.value)) - } else if (element.getAttribute('role') === 'listbox') { - return JSON.stringify( - element.querySelector('[aria-selected="true"]')?.innerHTML, - ) - } else if (element.getAttribute('role') === 'option') { - return JSON.stringify(element.innerHTML) - } else if ( - isElementType(element, 'input', {type: 'checkbox'}) || - isElementType(element, 'input', {type: 'radio'}) || - isElementType(element, 'button') - ) { - // handled separately - return null - } - - return JSON.stringify((element as HTMLInputElement).value) -} - -function hasProperty( - obj: T, - prop: K, -): obj is T & {[k in K]: unknown} { - return prop in obj -} - -function getElementDisplayName(element: Element) { - const value = getElementValue(element) - const hasChecked = - isElementType(element, 'input', {type: 'checkbox'}) || - isElementType(element, 'input', {type: 'radio'}) - return [ - element.tagName.toLowerCase(), - element.id ? `#${element.id}` : null, - hasProperty(element, 'name') && element.name - ? `[name="${element.name}"]` - : null, - hasProperty(element, 'htmlFor') && element.htmlFor - ? `[for="${element.htmlFor}"]` - : null, - value ? `[value=${value}]` : null, - hasChecked ? `[checked=${element.checked}]` : null, - isElementType(element, 'option') ? `[selected=${element.selected}]` : null, - element.getAttribute('role') === 'option' - ? `[aria-selected=${element.getAttribute('aria-selected')}]` - : null, - ] - .filter(Boolean) - .join('') -} - -type CallData = { - event: Event - elementDisplayName: string - testData?: TestData -} - -function isElement(target: EventTarget): target is Element { - return 'tagName' in target -} - -function isMouseEvent(event: Event): event is MouseEvent { - return ( - event.constructor.name === 'MouseEvent' || - event.type === 'click' || - event.type.startsWith('mouse') - ) -} - -function isPointerEvent(event: Event): event is PointerEvent { - return event.type.startsWith('pointer') -} - -function addListeners( - element: Element & {testData?: TestData}, - { - eventHandlers = {}, - }: { - eventHandlers?: EventHandlers - } = {}, -) { - const eventHandlerCalls: {current: CallData[]} = {current: []} - const generalListener = jest - .fn((event: Event) => { - const target = event.target - const callData: CallData = { - event, - elementDisplayName: - target && isElement(target) ? getElementDisplayName(target) : '', - } - if (element.testData && !element.testData.handled) { - callData.testData = element.testData - // sometimes firing a single event (like click on a checkbox) will - // automatically fire more events (line input and change). - // and we don't want the test data applied to those, so we'll store - // this and not add the testData to our call if that was already handled - element.testData.handled = true - } - eventHandlerCalls.current.push(callData) - }) - .mockName('eventListener') - const listeners = Object.keys(eventMap) - - for (const name of listeners) { - addEventListener(element, name.toLowerCase(), (...args) => { - if (name in eventHandlers) { - generalListener(...args) - return eventHandlers[name](...args) - } - return generalListener(...args) - }) - } - // prevent default of submits in tests - if (isElementType(element, 'form')) { - addEventListener(element, 'submit', e => e.preventDefault()) - } - - function getEventSnapshot() { - const eventCalls = eventHandlerCalls.current - .map(({event, testData, elementDisplayName}) => { - const eventName = event.constructor.name - const eventLabel = - eventName in eventLabelGetters - ? eventLabelGetters[eventName as keyof typeof eventLabelGetters]( - event as KeyboardEvent & MouseEvent, - ) - : '' - const modifiers = ['altKey', 'shiftKey', 'metaKey', 'ctrlKey'] - .filter(key => event[key as keyof Event]) - .map(k => `{${k.replace('Key', '')}}`) - .join('') - - const firstLine = [ - `${elementDisplayName} - ${event.type}`, - [eventLabel, modifiers].filter(Boolean).join(' '), - ] - .filter(Boolean) - .join(': ') - - return [firstLine, testData?.before ? getChanges(testData) : null] - .filter(Boolean) - .join('\n') - }) - .join('\n') - .trim() - if (eventCalls.length) { - return { - snapshot: [ - `Events fired on: ${getElementDisplayName(element)}`, - eventCalls, - ].join('\n\n'), - } - } else { - return { - snapshot: `No events were fired on: ${getElementDisplayName(element)}`, - } - } - } - const clearEventCalls = () => { - generalListener.mockClear() - eventHandlerCalls.current = [] - } - const getEvents = ( - type?: T, - ): Array => - generalListener.mock.calls - .map(([e]) => e) - .filter(e => !type || e.type === type) as Array - const eventWasFired = (eventType: keyof GlobalEventHandlersEventMap) => - getEvents(eventType).length > 0 - - function getClickEventsSnapshot() { - const lines = getEvents().map(e => - isMouseEvent(e) - ? `${e.type} - button=${e.button}; buttons=${e.buttons}; detail=${e.detail}` - : isPointerEvent(e) - ? `${e.type} - pointerId=${e.pointerId}; pointerType=${e.pointerType}; isPrimary=${e.isPrimary}` - : e.type, - ) - return {snapshot: lines.join('\n')} - } - - return { - getEventSnapshot, - getClickEventsSnapshot, - clearEventCalls, - getEvents, - eventWasFired, - } -} - -function getValueWithSelection({ - value, - selectionStart, - selectionEnd, -}: TestDataProps = {}) { - return [ - value?.slice(0, selectionStart ?? undefined), - ...(selectionStart === selectionEnd - ? ['{CURSOR}'] - : [ - '{SELECTION}', - value?.slice(selectionStart ?? 0, selectionEnd ?? undefined), - '{/SELECTION}', - ]), - value?.slice(selectionEnd ?? undefined), - ].join('') -} - -const changeLabelGetter: Record string> = { - value: ({before, after}) => - [ - JSON.stringify(getValueWithSelection(before)), - JSON.stringify(getValueWithSelection(after)), - ].join(' -> '), - checked: ({before, after}) => - [ - (before as HTMLInputElement).checked ? 'checked' : 'unchecked', - (after as HTMLInputElement).checked ? 'checked' : 'unchecked', - ].join(' -> '), - - // unfortunately, changing a select option doesn't happen within fireEvent - // but rather imperatively via `options.selected = newValue` - // because of this we don't (currently) have a way to track before/after - // in a given fireEvent call. -} -changeLabelGetter.selectionStart = changeLabelGetter.value -changeLabelGetter.selectionEnd = changeLabelGetter.value - -const getDefaultLabel = ({ - key, - before, - after, -}: { - key: keyof T - before: T - after: T -}) => `${key}: ${JSON.stringify(before[key])} -> ${JSON.stringify(after[key])}` - -function getChanges({before, after}: TestData) { - const changes = new Set() - if (before && after) { - for (const key of Object.keys(before) as Array) { - if (after[key] !== before[key]) { - changes.add( - (key in changeLabelGetter ? changeLabelGetter[key] : getDefaultLabel)( - {key, before, after}, - ), - ) - } - } - } - - return Array.from(changes) - .filter(Boolean) - .map(line => ` ${line}`) - .join('\n') -} - -// eslint-disable-next-line jest/prefer-hooks-on-top -afterEach(() => { - for (const {el, type, listener} of eventListeners) { - el.removeEventListener(type, listener) - } - eventListeners = [] - document.body.innerHTML = '' -}) - -export {setup, setupSelect, setupListbox, addEventListener, addListeners} diff --git a/tests/clipboard/copy.ts b/tests/clipboard/copy.ts index 86f9ab47..9ccd8144 100644 --- a/tests/clipboard/copy.ts +++ b/tests/clipboard/copy.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('copy selected value', async () => { const {element, getEvents} = setup( diff --git a/tests/clipboard/cut.ts b/tests/clipboard/cut.ts index 5255aa3f..30619cd3 100644 --- a/tests/clipboard/cut.ts +++ b/tests/clipboard/cut.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('cut selected value', async () => { const {element, getEvents} = setup( diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index 670b6fa2..8f993977 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('should paste text in input', async () => { const {element, getEventSnapshot} = setup('') diff --git a/tests/convenience/click.ts b/tests/convenience/click.ts index 74a56e6b..143b2798 100644 --- a/tests/convenience/click.ts +++ b/tests/convenience/click.ts @@ -1,5 +1,5 @@ import userEvent, {PointerEventsCheckLevel} from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' describe.each([ ['click', {clickCount: 1}], diff --git a/tests/convenience/hover.ts b/tests/convenience/hover.ts index f83abce5..c33fa743 100644 --- a/tests/convenience/hover.ts +++ b/tests/convenience/hover.ts @@ -1,5 +1,5 @@ import userEvent, {PointerEventsCheckLevel} from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' describe.each([ ['hover', {events: ['over', 'enter', 'move']}], diff --git a/tests/convenience/tab.ts b/tests/convenience/tab.ts index 293de42a..904f26c3 100644 --- a/tests/convenience/tab.ts +++ b/tests/convenience/tab.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('tab', async () => { const { diff --git a/tests/document/index.ts b/tests/document/index.ts index 98f0f056..8120d39b 100644 --- a/tests/document/index.ts +++ b/tests/document/index.ts @@ -1,4 +1,4 @@ -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' import { prepareDocument, getUIValue, diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts index 6799cddb..37422449 100644 --- a/tests/dom/customElement.ts +++ b/tests/dom/customElement.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {addListeners} from '#testHelpers/utils' +import {addListeners} from '#testHelpers' // It is unclear which part of our implementation is targeted with this test. // Can this be removed? Is it sufficient? diff --git a/tests/event/behavior/keydown.ts b/tests/event/behavior/keydown.ts index cd24690e..04535db3 100644 --- a/tests/event/behavior/keydown.ts +++ b/tests/event/behavior/keydown.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test.each(['Backspace', 'Delete', 'End', 'Home', 'PageUp', 'PageDown'])( 'implement no keydown behavior for [%s] outside of editable context', diff --git a/tests/event/behavior/keyup.ts b/tests/event/behavior/keyup.ts index 2658b39e..96ce8eeb 100644 --- a/tests/event/behavior/keyup.ts +++ b/tests/event/behavior/keyup.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' describe('release [Space]', () => { test.each([ diff --git a/tests/event/dispatchEvent.ts b/tests/event/dispatchEvent.ts index ee693a1c..afe21c2d 100644 --- a/tests/event/dispatchEvent.ts +++ b/tests/event/dispatchEvent.ts @@ -1,7 +1,7 @@ import {dispatchUIEvent} from '#src/event' import {behavior, BehaviorPlugin} from '#src/event/behavior' import {createConfig} from '#src/setup/setup' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' jest.mock('#src/event/behavior', () => ({ behavior: { diff --git a/tests/keyboard/arrow.ts b/tests/keyboard/arrow.ts index ae62a359..8e862488 100644 --- a/tests/keyboard/arrow.ts +++ b/tests/keyboard/arrow.ts @@ -1,6 +1,6 @@ import userEvent from '#src' import {setSelection} from '#src/utils' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' describe('in text input', () => { test('collapse selection to the left', async () => { diff --git a/tests/keyboard/character.ts b/tests/keyboard/character.ts index 03086a8f..eb659e6f 100644 --- a/tests/keyboard/character.ts +++ b/tests/keyboard/character.ts @@ -1,7 +1,7 @@ import cases from 'jest-in-case' import {getUIValue} from '#src/document/value' import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('type character into input', async () => { const {element} = setup(``) diff --git a/tests/keyboard/combination.ts b/tests/keyboard/combination.ts index f5f57370..358ec996 100644 --- a/tests/keyboard/combination.ts +++ b/tests/keyboard/combination.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('select input per `Control+A`', async () => { const {element} = setup(``) diff --git a/tests/keyboard/control.ts b/tests/keyboard/control.ts index ac969307..8e556b2c 100644 --- a/tests/keyboard/control.ts +++ b/tests/keyboard/control.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('press [Home] in textarea', async () => { const {element} = setup( diff --git a/tests/keyboard/functional.ts b/tests/keyboard/functional.ts index cad907d1..f5aa1a3e 100644 --- a/tests/keyboard/functional.ts +++ b/tests/keyboard/functional.ts @@ -1,7 +1,7 @@ import cases from 'jest-in-case' import userEvent from '#src' import {getUISelection, setUISelection, setUIValue} from '#src/document' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' import {setSelection} from '#src/utils' test('produce extra events for the Control key when AltGraph is pressed', async () => { @@ -170,9 +170,9 @@ test('trigger change event on [Space] keyup on HTMLInputElement type=radio', asy input[checked=false] - focus input[checked=false] - focusin - input[checked=false] - keydown - input[checked=false] - keypress - input[checked=false] - keyup + input[checked=false] - keydown: Space + input[checked=false] - keypress: Space + input[checked=false] - keyup: Space input[checked=true] - click: primary unchecked -> checked input[checked=true] - input diff --git a/tests/keyboard/index.ts b/tests/keyboard/index.ts index f1857a50..9517853d 100644 --- a/tests/keyboard/index.ts +++ b/tests/keyboard/index.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {addListeners, setup} from '#testHelpers/utils' +import {addListeners, setup} from '#testHelpers' it('type without focus', async () => { const {element} = setup('') diff --git a/tests/keyboard/keyboardAction.ts b/tests/keyboard/keyboardAction.ts index d56ba7f2..8a132e64 100644 --- a/tests/keyboard/keyboardAction.ts +++ b/tests/keyboard/keyboardAction.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('no character input if `altKey` or `ctrlKey` is pressed', async () => { const {element, eventWasFired} = setup(``) diff --git a/tests/keyboard/modifiers.ts b/tests/keyboard/modifiers.ts index 0bc51ae4..f4dfd15a 100644 --- a/tests/keyboard/modifiers.ts +++ b/tests/keyboard/modifiers.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test.each([ ['Shift', 'shiftKey'], diff --git a/tests/pointer/click.ts b/tests/pointer/click.ts index 66bd4d23..7bcc7b4f 100644 --- a/tests/pointer/click.ts +++ b/tests/pointer/click.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('click element', async () => { const {element, getClickEventsSnapshot, getEvents} = setup('
') diff --git a/tests/pointer/drag.ts b/tests/pointer/drag.ts index 1bdb49f4..08367f60 100644 --- a/tests/pointer/drag.ts +++ b/tests/pointer/drag.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('drag sequence', async () => { const {element, getClickEventsSnapshot} = setup(`
`) diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index 08f2d43a..e71d3b47 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -1,5 +1,5 @@ import userEvent, {PointerEventsCheckLevel} from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('continue previous target', async () => { const {element, getEvents} = setup(`
`) diff --git a/tests/pointer/move.ts b/tests/pointer/move.ts index d3467d43..287172c7 100644 --- a/tests/pointer/move.ts +++ b/tests/pointer/move.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('hover to other element', async () => { const {elements, getEventSnapshot} = setup(`
`) diff --git a/tests/pointer/select.ts b/tests/pointer/select.ts index 284ebfec..b6a32262 100644 --- a/tests/pointer/select.ts +++ b/tests/pointer/select.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' // On an unprevented mousedown the browser moves the cursor to the closest character. // As we have no layout, we are not able to determine the correct character. diff --git a/tests/react/type.tsx b/tests/react/type.tsx index 4d7f0ee1..b0ffb3d5 100644 --- a/tests/react/type.tsx +++ b/tests/react/type.tsx @@ -1,7 +1,7 @@ import React, {useState} from 'react' import {render, screen} from '@testing-library/react' import userEvent from '#src' -import {addListeners} from '#testHelpers/utils' +import {addListeners} from '#testHelpers' test('trigger onChange SyntheticEvent on input', async () => { const inputHandler = jest.fn() diff --git a/tests/setup/index.ts b/tests/setup/index.ts index 7de86d61..6faa9d4f 100644 --- a/tests/setup/index.ts +++ b/tests/setup/index.ts @@ -1,7 +1,7 @@ import cases from 'jest-in-case' import userEvent from '#src' import {UserEventApi} from '#src/setup' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' /// start of mocking diff --git a/tests/tsconfig.json b/tests/tsconfig.json index b16d4358..d5ce52b1 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -5,7 +5,7 @@ "paths": { "#src": ["./src/index"], "#src/*": ["./src/*"], - "#testHelpers/*": ["./tests/_helpers/*"] + "#testHelpers": ["./tests/_helpers/index"] } }, "include": ["."] diff --git a/tests/utility/clear.ts b/tests/utility/clear.ts index 32c06e1d..ac96059d 100644 --- a/tests/utility/clear.ts +++ b/tests/utility/clear.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' describe('clear elements', () => { test('clear text input', async () => { diff --git a/tests/utility/selectOptions/_setup.ts b/tests/utility/selectOptions/_setup.ts new file mode 100644 index 00000000..a41443de --- /dev/null +++ b/tests/utility/selectOptions/_setup.ts @@ -0,0 +1,73 @@ +import {addListeners} from '#testHelpers' + +export function setupSelect({ + disabled = false, + disabledOptions = false, + multiple = false, + pointerEvents = 'auto', +} = {}) { + const form = document.createElement('form') + form.innerHTML = ` + + ` + document.body.append(form) + const select = form.querySelector('select') as HTMLSelectElement + const options = Array.from(form.querySelectorAll('option')) + return { + ...addListeners(select), + form, + select, + options, + } +} + +export function setupListbox() { + const wrapper = document.createElement('div') + wrapper.innerHTML = ` + +
    +
  • 1
  • +
  • 2
  • +
  • 3
  • +
+ ` + document.body.append(wrapper) + const listbox = wrapper.querySelector('[role="listbox"]') as HTMLUListElement + const options = Array.from( + wrapper.querySelectorAll('[role="option"]'), + ) + + // the user is responsible for handling aria-selected on listbox options + options.forEach(el => + el.addEventListener('click', e => { + const target = e.currentTarget as HTMLElement + target.setAttribute( + 'aria-selected', + JSON.stringify( + !JSON.parse(String(target.getAttribute('aria-selected'))), + ), + ) + }), + ) + + return { + ...addListeners(listbox), + listbox, + options, + } +} diff --git a/tests/utility/selectOptions/deselect.ts b/tests/utility/selectOptions/deselect.ts index 7974a241..f43cffbe 100644 --- a/tests/utility/selectOptions/deselect.ts +++ b/tests/utility/selectOptions/deselect.ts @@ -1,5 +1,6 @@ +import {setupSelect} from './_setup' import userEvent from '#src' -import {addListeners, setupSelect, setup} from '#testHelpers/utils' +import {addListeners, setup} from '#testHelpers' test('fires correct events', async () => { const {form, select, options, getEventSnapshot} = setupSelect({ diff --git a/tests/utility/selectOptions/select.ts b/tests/utility/selectOptions/select.ts index af031656..67569ef9 100644 --- a/tests/utility/selectOptions/select.ts +++ b/tests/utility/selectOptions/select.ts @@ -1,10 +1,6 @@ +import {setupListbox, setupSelect} from './_setup' import userEvent, {PointerEventsCheckLevel} from '#src' -import { - setupSelect, - addListeners, - setupListbox, - setup, -} from '#testHelpers/utils' +import {addListeners, setup} from '#testHelpers' test('fires correct events', async () => { const {select, options, getEventSnapshot} = setupSelect() diff --git a/tests/utility/type.ts b/tests/utility/type.ts index 185274a3..a43c0964 100644 --- a/tests/utility/type.ts +++ b/tests/utility/type.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('type into input', async () => { const {element, getEventSnapshot} = setup('') diff --git a/tests/utility/upload.ts b/tests/utility/upload.ts index d8327e07..3e07708c 100644 --- a/tests/utility/upload.ts +++ b/tests/utility/upload.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('change file input', async () => { const file = new File(['hello'], 'hello.png', {type: 'image/png'}) diff --git a/tests/utils/edit/calculateNewValue.ts b/tests/utils/edit/calculateNewValue.ts index 87792f79..776c5a92 100644 --- a/tests/utils/edit/calculateNewValue.ts +++ b/tests/utils/edit/calculateNewValue.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' // TODO: focus the maxlength tests on the tested aspects diff --git a/tests/utils/edit/input.ts b/tests/utils/edit/input.ts index 9be70559..aa4d5f8c 100644 --- a/tests/utils/edit/input.ts +++ b/tests/utils/edit/input.ts @@ -1,6 +1,6 @@ import cases from 'jest-in-case' import {input} from '#src/utils' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' import {createConfig} from '#src/setup/setup' cases( diff --git a/tests/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts index 070efd67..aab44bcd 100644 --- a/tests/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -1,4 +1,4 @@ -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' import {isContentEditable} from '#src/utils' test('report if element is contenteditable', async () => { diff --git a/tests/utils/edit/setFiles.ts b/tests/utils/edit/setFiles.ts index 58b5a35b..8168f18e 100644 --- a/tests/utils/edit/setFiles.ts +++ b/tests/utils/edit/setFiles.ts @@ -1,5 +1,5 @@ import {createFileList, setFiles} from '#src/utils' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('set files', () => { const {element} = setup( diff --git a/tests/utils/focus/blur.ts b/tests/utils/focus/blur.ts index ed5286f5..775b4400 100644 --- a/tests/utils/focus/blur.ts +++ b/tests/utils/focus/blur.ts @@ -1,5 +1,5 @@ import {blur, focus} from '#src/utils' -import {setup} from '#testHelpers/utils' +import {setup} from '#testHelpers' test('blur a button', async () => { const {element, getEventSnapshot, clearEventCalls} = setup(``) + const {element, eventWasFired, user} = setup( + `
`, + ) - await userEvent.pointer({keys: '[MouseLeft]', target: element.children[0]}) + await user.pointer({keys: '[MouseLeft]', target: element.children[0]}) expect(eventWasFired('submit')).toBe(true) }) test('does not submit a form when clicking on a `, ) - await userEvent.pointer({keys: '[MouseLeft]', target: element.children[0]}) + await user.pointer({keys: '[MouseLeft]', target: element.children[0]}) expect(eventWasFired('submit')).toBe(false) }) }) test('secondary mouse button fires `contextmenu` instead of `click`', async () => { - const {element, getEvents, clearEventCalls} = setup(`
`) - await userEvent.pointer({keys: '[MouseLeft>]', target: element.children[1]}) + await user.pointer({keys: '[MouseLeft>]', target: element.children[1]}) expect(element.children[1]).toHaveFocus() - await userEvent.pointer({keys: '[TouchA]', target: element.children[0]}) + await user.pointer({keys: '[TouchA]', target: element.children[0]}) expect(element).toHaveFocus() }) test('blur when outside of focusable context', async () => { const { elements: [focusable, notFocusable], + user, } = setup(`
`) focusable.focus() - await userEvent.pointer({keys: '[MouseLeft>]', target: notFocusable}) + await user.pointer({keys: '[MouseLeft>]', target: notFocusable}) expect(document.body).toHaveFocus() }) test('mousedown handlers can prevent moving focus', async () => { - const {element} = setup(``) + const {element, user} = setup(``) element.addEventListener('mousedown', e => e.preventDefault()) - await userEvent.pointer({keys: '[MouseLeft>]', target: element}) - await userEvent.pointer({keys: '[TouchA]', target: element}) + await user.pointer({keys: '[MouseLeft>]', target: element}) + await user.pointer({keys: '[TouchA]', target: element}) expect(element).not.toHaveFocus() expect(element).toHaveProperty('selectionStart', 0) }) test('single mousedown moves cursor to the last text', async () => { - const {element} = setup( + const {element, user} = setup( `
foo bar baz
`, ) - await userEvent.pointer({keys: '[MouseLeft>]', target: element}) + await user.pointer({keys: '[MouseLeft>]', target: element}) expect(element).toHaveFocus() expect(document.getSelection()).toHaveProperty( @@ -70,14 +72,16 @@ test('single mousedown moves cursor to the last text', async () => { }) test('double mousedown selects a word or a sequence of whitespace', async () => { - const {element} = setup(``) + const {element, user} = setup( + ``, + ) - await userEvent.pointer({keys: '[MouseLeft][MouseLeft>]', target: element}) + await user.pointer({keys: '[MouseLeft][MouseLeft>]', target: element}) expect(element).toHaveProperty('selectionStart', 8) expect(element).toHaveProperty('selectionEnd', 11) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft>]', target: element, offset: 0, @@ -86,7 +90,7 @@ test('double mousedown selects a word or a sequence of whitespace', async () => expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 3) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft]', target: element, offset: 11, @@ -97,16 +101,18 @@ test('double mousedown selects a word or a sequence of whitespace', async () => element.value = 'foo bar ' - await userEvent.pointer({keys: '[MouseLeft][MouseLeft>]', target: element}) + await user.pointer({keys: '[MouseLeft][MouseLeft>]', target: element}) expect(element).toHaveProperty('selectionStart', 7) expect(element).toHaveProperty('selectionEnd', 9) }) test('triple mousedown selects whole line', async () => { - const {element} = setup(``) + const {element, user} = setup( + ``, + ) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft][MouseLeft>]', target: element, }) @@ -114,7 +120,7 @@ test('triple mousedown selects whole line', async () => { expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 11) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft][MouseLeft>]', target: element, offset: 0, @@ -123,7 +129,7 @@ test('triple mousedown selects whole line', async () => { expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 11) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft][MouseLeft>]', target: element, offset: 11, @@ -134,9 +140,11 @@ test('triple mousedown selects whole line', async () => { }) test('mousemove with pressed button extends selection', async () => { - const {element} = setup(``) + const {element, user} = setup( + ``, + ) - const pointerState = await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft>]', target: element, offset: 6, @@ -145,34 +153,34 @@ test('mousemove with pressed button extends selection', async () => { expect(element).toHaveProperty('selectionStart', 4) expect(element).toHaveProperty('selectionEnd', 7) - await userEvent.pointer({offset: 2}, {pointerState}) + await user.pointer({offset: 2}) expect(element).toHaveProperty('selectionStart', 2) expect(element).toHaveProperty('selectionEnd', 7) - await userEvent.pointer({offset: 10}, {pointerState}) + await user.pointer({offset: 10}) expect(element).toHaveProperty('selectionStart', 4) expect(element).toHaveProperty('selectionEnd', 10) - await userEvent.pointer({}, {pointerState}) + await user.pointer({}) expect(element).toHaveProperty('selectionStart', 4) expect(element).toHaveProperty('selectionEnd', 11) - await userEvent.pointer({offset: 5}, {pointerState}) + await user.pointer({offset: 5}) expect(element).toHaveProperty('selectionStart', 4) expect(element).toHaveProperty('selectionEnd', 7) }) test('selection is moved on non-input elements', async () => { - const {element} = setup( + const {element, user} = setup( `
foo bar baz
`, ) const span = element.querySelectorAll('span') - const pointerState = await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft][MouseLeft>]', target: element, offset: 6, @@ -193,7 +201,7 @@ test('selection is moved on non-input elements', async () => { ) expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) - await userEvent.pointer({offset: 2}, {pointerState}) + await user.pointer({offset: 2}) expect(document.getSelection()?.toString()).toBe('o bar') expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( @@ -210,7 +218,7 @@ test('selection is moved on non-input elements', async () => { ) expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) - await userEvent.pointer({offset: 10}, {pointerState}) + await user.pointer({offset: 10}) expect(document.getSelection()?.toString()).toBe('bar ba') expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( @@ -227,7 +235,7 @@ test('selection is moved on non-input elements', async () => { ) expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 2) - await userEvent.pointer({}, {pointerState}) + await user.pointer({}) expect(document.getSelection()?.toString()).toBe('bar baz') expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( @@ -246,19 +254,19 @@ test('selection is moved on non-input elements', async () => { }) test('`node` overrides the text offset approximation', async () => { - const {element} = setup( + const {element, user} = setup( `
foo bar
baz
`, ) const div = element.firstChild as HTMLDivElement const span = element.querySelectorAll('span') - const pointerState = await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft>]', target: element, node: span[0].firstChild as Node, offset: 1, }) - await userEvent.pointer({node: div, offset: 3}, {pointerState}) + await user.pointer({node: div, offset: 3}) expect(document.getSelection()?.toString()).toBe('oo bar') expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( @@ -275,7 +283,7 @@ test('`node` overrides the text offset approximation', async () => { ) expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft]', target: element, node: span[0].firstChild as Node, @@ -295,7 +303,7 @@ test('`node` overrides the text offset approximation', async () => { ) expect(document.getSelection()?.getRangeAt(0)).toHaveProperty('endOffset', 3) - await userEvent.pointer({ + await user.pointer({ keys: '[MouseLeft]', target: element, node: span[0] as Node, @@ -320,16 +328,17 @@ describe('focus control when clicking label', () => { test('click event on label moves focus to control', async () => { const { elements: [input, label], + user, } = setup(`