From 1aa2027e5ec445ab413808556efa7763b65053d3 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 9 Aug 2022 17:21:20 +0200 Subject: [PATCH] fix: remove circular dependencies (#1027) --- src/clipboard/copy.ts | 9 +- src/clipboard/cut.ts | 9 +- src/clipboard/paste.ts | 4 +- src/convenience/click.ts | 4 +- src/convenience/hover.ts | 7 +- src/document/{selection.ts => UI.ts} | 150 +++---- .../focus => document}/copySelection.ts | 16 +- .../getValueOrTextContent.ts} | 10 +- src/document/index.ts | 90 +--- src/document/interceptor.ts | 133 ++++++ src/document/prepareDocument.ts | 76 ++++ src/document/setRangeText.ts | 21 - src/document/trackValue.ts | 85 ++++ src/document/value.ts | 172 -------- src/event/behavior/click.ts | 21 +- src/event/behavior/cut.ts | 7 +- src/event/behavior/keydown.ts | 58 ++- src/event/behavior/keypress.ts | 16 +- src/event/behavior/keyup.ts | 9 +- src/event/behavior/paste.ts | 7 +- src/event/behavior/registry.ts | 4 +- src/event/dispatchEvent.ts | 38 +- src/event/focus.ts | 30 ++ src/event/index.ts | 38 +- src/{utils/edit => event}/input.ts | 63 ++- .../edit/walkRadio.ts => event/radio.ts} | 14 +- src/event/selection/getInputRange.ts | 18 + .../selection/getTargetTypeAndSelection.ts | 34 ++ src/event/selection/index.ts | 17 + src/event/selection/modifySelection.ts | 29 ++ .../selection/modifySelectionPerMouse.ts | 54 +++ src/event/selection/moveSelection.ts | 46 ++ .../selection}/resolveCaretPosition.ts | 2 +- .../focus => event/selection}/selectAll.ts | 17 +- src/event/selection/setSelection.ts | 31 ++ src/event/selection/setSelectionPerMouse.ts | 111 +++++ src/event/selection/setSelectionRange.ts | 35 ++ src/event/selection/updateSelectionOnFocus.ts | 36 ++ src/keyboard/index.ts | 28 +- src/options.ts | 28 -- src/pointer/index.ts | 44 +- src/setup/config.ts | 7 - src/setup/directApi.ts | 14 +- src/setup/index.ts | 22 +- src/setup/setup.ts | 125 ++++-- src/setup/wrapAsync.ts | 4 +- src/system/keyboard.ts | 21 +- src/system/pointer/buttons.ts | 2 +- src/system/pointer/index.ts | 83 ++-- src/system/pointer/mouse.ts | 72 ++- src/system/pointer/pointer.ts | 68 +-- src/system/pointer/shared.ts | 35 ++ src/utility/clear.ts | 16 +- src/utility/selectOptions.ts | 27 +- src/utility/type.ts | 14 +- src/utility/upload.ts | 4 +- src/utils/click/isClickableInput.ts | 9 +- src/utils/dataTransfer/Clipboard.ts | 3 +- src/utils/edit/isEditable.ts | 23 +- src/utils/edit/isValidDateOrTimeValue.ts | 8 - src/utils/edit/maxLength.ts | 35 +- .../edit/{buildTimeValue.ts => timeValue.ts} | 9 + src/utils/focus/blur.ts | 14 - src/utils/focus/cursor.ts | 3 +- src/utils/focus/focus.ts | 25 -- src/utils/focus/selection.ts | 411 +----------------- src/utils/index.ts | 11 +- src/utils/misc/eventWrapper.ts | 9 - src/utils/misc/level.ts | 21 +- src/utils/misc/wait.ts | 4 +- src/utils/pointer/cssPointerEvents.ts | 32 +- tests/_helpers/setup.ts | 3 +- tests/document/index.ts | 2 +- tests/event/behavior/keydown.ts | 31 +- tests/event/behavior/keypress.ts | 23 +- tests/event/behavior/keyup.ts | 9 +- tests/event/dispatchEvent.ts | 13 +- tests/{utils/focus => event}/focus.ts | 71 ++- tests/{utils/edit => event}/input.ts | 27 +- .../selection.ts => event/selection/index.ts} | 16 +- .../focus => event/selection}/selectAll.ts | 2 +- tests/setup/_mockApis.ts | 2 +- tests/setup/index.ts | 25 +- tests/system/pointer.ts | 28 +- tests/utils/focus/blur.ts | 53 --- tests/utils/pointer/cssPointerEvents.ts | 22 +- 86 files changed, 1537 insertions(+), 1542 deletions(-) rename src/document/{selection.ts => UI.ts} (52%) rename src/{utils/focus => document}/copySelection.ts (60%) rename src/{utils/edit/getValue.ts => document/getValueOrTextContent.ts} (53%) create mode 100644 src/document/prepareDocument.ts delete mode 100644 src/document/setRangeText.ts create mode 100644 src/document/trackValue.ts delete mode 100644 src/document/value.ts create mode 100644 src/event/focus.ts rename src/{utils/edit => event}/input.ts (80%) rename src/{utils/edit/walkRadio.ts => event/radio.ts} (69%) create mode 100644 src/event/selection/getInputRange.ts create mode 100644 src/event/selection/getTargetTypeAndSelection.ts create mode 100644 src/event/selection/index.ts create mode 100644 src/event/selection/modifySelection.ts create mode 100644 src/event/selection/modifySelectionPerMouse.ts create mode 100644 src/event/selection/moveSelection.ts rename src/{utils/focus => event/selection}/resolveCaretPosition.ts (98%) rename src/{utils/focus => event/selection}/selectAll.ts (68%) create mode 100644 src/event/selection/setSelection.ts create mode 100644 src/event/selection/setSelectionPerMouse.ts create mode 100644 src/event/selection/setSelectionRange.ts create mode 100644 src/event/selection/updateSelectionOnFocus.ts delete mode 100644 src/setup/config.ts create mode 100644 src/system/pointer/shared.ts delete mode 100644 src/utils/edit/isValidDateOrTimeValue.ts rename src/utils/edit/{buildTimeValue.ts => timeValue.ts} (82%) delete mode 100644 src/utils/focus/blur.ts delete mode 100644 src/utils/focus/focus.ts delete mode 100644 src/utils/misc/eventWrapper.ts rename tests/{utils/focus => event}/focus.ts (59%) rename tests/{utils/edit => event}/input.ts (93%) rename tests/{utils/focus/selection.ts => event/selection/index.ts} (98%) rename tests/{utils/focus => event/selection}/selectAll.ts (97%) delete mode 100644 tests/utils/focus/blur.ts diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index 475c2afc..dc4c5fe1 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,8 +1,9 @@ -import {Config, Instance} from '../setup' -import {copySelection, writeDataTransferToClipboard} from '../utils' +import {copySelection} from '../document' +import type {Instance} from '../setup' +import {writeDataTransferToClipboard} from '../utils' export async function copy(this: Instance) { - const doc = this[Config].document + const doc = this.config.document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body const clipboardData = copySelection(target) @@ -15,7 +16,7 @@ export async function copy(this: Instance) { this.dispatchUIEvent(target, 'copy', { clipboardData, }) && - this[Config].writeToClipboard + this.config.writeToClipboard ) { await writeDataTransferToClipboard(doc, clipboardData) } diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index e20f7d1d..f267cfd9 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,8 +1,9 @@ -import {Config, Instance} from '../setup' -import {copySelection, writeDataTransferToClipboard} from '../utils' +import {copySelection} from '../document' +import type {Instance} from '../setup' +import {writeDataTransferToClipboard} from '../utils' export async function cut(this: Instance) { - const doc = this[Config].document + const doc = this.config.document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body const clipboardData = copySelection(target) @@ -15,7 +16,7 @@ export async function cut(this: Instance) { this.dispatchUIEvent(target, 'cut', { clipboardData, }) && - this[Config].writeToClipboard + this.config.writeToClipboard ) { await writeDataTransferToClipboard(target.ownerDocument, clipboardData) } diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 54f03381..ab2e98d5 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,4 +1,4 @@ -import {Config, Instance} from '../setup' +import type {Instance} from '../setup' import { createDataTransfer, getWindow, @@ -9,7 +9,7 @@ export async function paste( this: Instance, clipboardData?: DataTransfer | string, ) { - const doc = this[Config].document + const doc = this.config.document const target = doc.activeElement ?? /* istanbul ignore next */ doc.body const dataTransfer: DataTransfer = diff --git a/src/convenience/click.ts b/src/convenience/click.ts index 04e08f3a..789db166 100644 --- a/src/convenience/click.ts +++ b/src/convenience/click.ts @@ -1,9 +1,9 @@ import type {PointerInput} from '../pointer' -import {Config, Instance} from '../setup' +import type {Instance} from '../setup' export async function click(this: Instance, element: Element): Promise { const pointerIn: PointerInput = [] - if (!this[Config].skipHover) { + if (!this.config.skipHover) { pointerIn.push({target: element}) } pointerIn.push({keys: '[MouseLeft]', target: element}) diff --git a/src/convenience/hover.ts b/src/convenience/hover.ts index 8503a66e..a342e45a 100644 --- a/src/convenience/hover.ts +++ b/src/convenience/hover.ts @@ -1,4 +1,4 @@ -import {Config, Instance} from '../setup' +import type {Instance} from '../setup' import {assertPointerEvents} from '../utils' export async function hover(this: Instance, element: Element) { @@ -6,9 +6,6 @@ export async function hover(this: Instance, element: Element) { } export async function unhover(this: Instance, element: Element) { - assertPointerEvents( - this[Config], - this[Config].system.pointer.getMouseTarget(this[Config]), - ) + assertPointerEvents(this, this.system.pointer.getMouseTarget(this)) return this.pointer({target: element.ownerDocument.body}) } diff --git a/src/document/selection.ts b/src/document/UI.ts similarity index 52% rename from src/document/selection.ts rename to src/document/UI.ts index 90f93b7c..4b60e04f 100644 --- a/src/document/selection.ts +++ b/src/document/UI.ts @@ -1,87 +1,81 @@ -import {getUIValue} from '.' -import {prepareInterceptor} from './interceptor' - +const UIValue = Symbol('Displayed value in UI') const UISelection = Symbol('Displayed selection in UI') +const InitialValue = Symbol('Initial value to compare on blur') -interface Value extends Number { - [UISelection]?: typeof UISelection -} - -export interface UISelectionRange { - startOffset: number - endOffset: number +declare global { + interface Element { + [UIValue]?: string + [InitialValue]?: string + [UISelection]?: UISelection + } } -export interface UISelection { +interface UISelection { anchorOffset: number focusOffset: number } -declare global { - interface Element { - [UISelection]?: UISelection +export type UIValueString = String & {[UIValue]: true} +export type UISelectionStart = Number & {[UISelection]: true} + +export function isUIValue( + value: string | UIValueString, +): value is UIValueString { + return typeof value === 'object' && UIValue in value +} + +export function isUISelectionStart( + start: number | UISelectionStart | null, +): start is UISelectionStart { + return !!start && typeof start === 'object' && UISelection in start +} + +export function setUIValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + if (element[InitialValue] === undefined) { + element[InitialValue] = element.value } + + element[UIValue] = value + + // eslint-disable-next-line no-new-wrappers + element.value = Object.assign(new String(value), { + [UIValue]: true, + }) as unknown as string +} + +export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) { + return element[UIValue] === undefined + ? element.value + : String(element[UIValue]) +} + +/** Flag the IDL value as clean. This does not change the value.*/ +export function setUIValueClean( + element: HTMLInputElement | HTMLTextAreaElement, +) { + element[UIValue] = undefined +} + +export function clearInitialValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + element[InitialValue] = undefined +} + +export function getInitialValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + return element[InitialValue] } -export function prepareSelectionInterceptor( +export function setUISelectionRaw( element: HTMLInputElement | HTMLTextAreaElement, + selection: UISelection, ) { - prepareInterceptor( - element, - 'setSelectionRange', - function interceptorImpl( - this: HTMLInputElement | HTMLTextAreaElement, - start: number | Value | null, - ...others - ) { - const isUI = start && typeof start === 'object' && start[UISelection] - - if (!isUI) { - this[UISelection] = undefined - } - - return { - applyNative: !!isUI, - realArgs: [Number(start), ...others] as [ - number, - number, - 'forward' | 'backward' | 'none' | undefined, - ], - } - }, - ) - - prepareInterceptor( - element, - 'selectionStart', - function interceptorImpl(this, v) { - this[UISelection] = undefined - - return {realArgs: v} - }, - ) - prepareInterceptor( - element, - 'selectionEnd', - function interceptorImpl(this, v) { - this[UISelection] = undefined - - return {realArgs: v} - }, - ) - - prepareInterceptor( - element, - 'select', - function interceptorImpl(this: HTMLInputElement | HTMLTextAreaElement) { - this[UISelection] = { - anchorOffset: 0, - focusOffset: getUIValue(element).length, - } - - return {realArgs: [] as []} - }, - ) + element[UISelection] = selection } export function setUISelection( @@ -120,20 +114,26 @@ export function setUISelection( } // eslint-disable-next-line no-new-wrappers - const startObj = new Number(startOffset) - ;(startObj as Value)[UISelection] = UISelection + const startObj = Object.assign(new Number(startOffset), { + [UISelection]: true, + }) as unknown as number try { - element.setSelectionRange(startObj as number, endOffset) + element.setSelectionRange(startObj, endOffset) } catch { // DOMException for invalid state is expected when calling this // on an element without support for setSelectionRange } } +export type UISelectionRange = UISelection & { + startOffset: number + endOffset: number +} + export function getUISelection( element: HTMLInputElement | HTMLTextAreaElement, -) { +): UISelectionRange { const sel = element[UISelection] ?? { anchorOffset: element.selectionStart ?? 0, focusOffset: element.selectionEnd ?? 0, diff --git a/src/utils/focus/copySelection.ts b/src/document/copySelection.ts similarity index 60% rename from src/utils/focus/copySelection.ts rename to src/document/copySelection.ts index bc221e52..e4e9802d 100644 --- a/src/utils/focus/copySelection.ts +++ b/src/document/copySelection.ts @@ -1,8 +1,10 @@ -import {getUISelection, getUIValue} from '../../document' -import {createDataTransfer} from '../dataTransfer/DataTransfer' -import {EditableInputType} from '../edit/isEditable' -import {getWindow} from '../misc/getWindow' -import {hasOwnSelection} from './selection' +import { + createDataTransfer, + EditableInputOrTextarea, + getWindow, + hasOwnSelection, +} from '../utils' +import {getUISelection, getUIValue} from './UI' export function copySelection(target: Element) { const data: Record = hasOwnSelection(target) @@ -20,9 +22,7 @@ export function copySelection(target: Element) { return dt } -function readSelectedValueFromInput( - target: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement, -) { +function readSelectedValueFromInput(target: EditableInputOrTextarea) { const sel = getUISelection(target) const val = getUIValue(target) diff --git a/src/utils/edit/getValue.ts b/src/document/getValueOrTextContent.ts similarity index 53% rename from src/utils/edit/getValue.ts rename to src/document/getValueOrTextContent.ts index 35cb09fe..55f2d5a3 100644 --- a/src/utils/edit/getValue.ts +++ b/src/document/getValueOrTextContent.ts @@ -1,10 +1,12 @@ -import {getUIValue} from '../../document' -import {isContentEditable} from './isContentEditable' +import {isContentEditable} from '../utils' +import {getUIValue} from './UI' -export function getValue( +export function getValueOrTextContent( element: T, ): T extends HTMLInputElement | HTMLTextAreaElement ? string : string | null -export function getValue(element: Element | null): string | null | undefined { +export function getValueOrTextContent( + element: Element | null, +): string | null | undefined { // istanbul ignore if if (!element) { return null diff --git a/src/document/index.ts b/src/document/index.ts index 31f1d576..62350a2e 100644 --- a/src/document/index.ts +++ b/src/document/index.ts @@ -1,87 +1,11 @@ -import {dispatchUIEvent} from '../event' -import {Config} from '../setup' -import {isElementType} from '../utils' -import {prepareSelectionInterceptor} from './selection' -import {prepareRangeTextInterceptor} from './setRangeText' -import { - clearInitialValue, - getInitialValue, - prepareValueInterceptor, -} 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 Element - - 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 (initialValue !== undefined) { - if (el.value !== initialValue) { - dispatchUIEvent({} as Config, el, 'change') - } - clearInitialValue(el) - } - }, - { - capture: true, - passive: true, - }, - ) - - document[isPrepared] = isPrepared -} - -function prepareElement(el: Element) { - if (el[isPrepared]) { - return - } - - if (isElementType(el, ['input', 'textarea'])) { - prepareValueInterceptor(el) - prepareSelectionInterceptor(el) - prepareRangeTextInterceptor(el) - } - - el[isPrepared] = isPrepared -} - export { + getUISelection, getUIValue, + setUISelection, setUIValue, - commitValueAfterInput, clearInitialValue, -} from './value' -export {getUISelection, setUISelection} from './selection' -export type {UISelectionRange} from './selection' +} from './UI' +export type {UISelectionRange} from './UI' +export {getValueOrTextContent} from './getValueOrTextContent' +export {copySelection} from './copySelection' +export {commitValueAfterInput} from './trackValue' diff --git a/src/document/interceptor.ts b/src/document/interceptor.ts index 2fe1977b..ec1abd3d 100644 --- a/src/document/interceptor.ts +++ b/src/document/interceptor.ts @@ -1,3 +1,16 @@ +import {isElementType} from '../utils' +import {startTrackValue, trackOrSetValue} from './trackValue' +import { + getUIValue, + isUISelectionStart, + isUIValue, + setUISelectionClean, + setUISelectionRaw, + setUIValueClean, + UISelectionStart, + UIValueString, +} from './UI' + const Interceptor = Symbol('Interceptor for programmatical calls') interface Interceptable { @@ -80,3 +93,123 @@ export function prepareInterceptor< [target]: intercept, }) } + +export function prepareValueInterceptor( + element: HTMLInputElement | HTMLTextAreaElement, +) { + prepareInterceptor( + element, + 'value', + function interceptorImpl( + this: HTMLInputElement | HTMLTextAreaElement, + v: UIValueString | string, + ) { + const isUI = isUIValue(v) + + if (isUI) { + startTrackValue(this) + } + + return { + applyNative: !!isUI, + realArgs: sanitizeValue(this, v), + then: isUI ? undefined : () => trackOrSetValue(this, String(v)), + } + }, + ) +} + +function sanitizeValue( + element: HTMLInputElement | HTMLTextAreaElement, + v: UIValueString | string, +) { + // Workaround for JSDOM + if ( + isElementType(element, 'input', {type: 'number'}) && + String(v) !== '' && + !Number.isNaN(Number(v)) + ) { + // Setting value to "1." results in `null` in JSDOM + return String(Number(v)) + } + return String(v) +} + +export function prepareSelectionInterceptor( + element: HTMLInputElement | HTMLTextAreaElement, +) { + prepareInterceptor( + element, + 'setSelectionRange', + function interceptorImpl( + this: HTMLInputElement | HTMLTextAreaElement, + start: number | UISelectionStart | null, + ...others: [ + end: number | null, + direction?: 'forward' | 'backward' | 'none', + ] + ) { + const isUI = isUISelectionStart(start) + + return { + applyNative: !!isUI, + realArgs: [Number(start), ...others] as [number, number, undefined], + then: () => (isUI ? undefined : setUISelectionClean(element)), + } + }, + ) + + prepareInterceptor( + element, + 'selectionStart', + function interceptorImpl(this, v) { + return { + realArgs: v, + then: () => setUISelectionClean(element), + } + }, + ) + prepareInterceptor( + element, + 'selectionEnd', + function interceptorImpl(this, v) { + return { + realArgs: v, + then: () => setUISelectionClean(element), + } + }, + ) + + prepareInterceptor( + element, + 'select', + function interceptorImpl(this: HTMLInputElement | HTMLTextAreaElement) { + return { + realArgs: [] as [], + then: () => + setUISelectionRaw(element, { + anchorOffset: 0, + focusOffset: getUIValue(element).length, + }), + } + }, + ) +} + +export function prepareRangeTextInterceptor( + element: HTMLInputElement | HTMLTextAreaElement, +) { + prepareInterceptor( + element, + 'setRangeText', + function interceptorImpl(...realArgs) { + return { + realArgs, + then: () => { + setUIValueClean(element) + setUISelectionClean(element) + }, + } + }, + ) +} diff --git a/src/document/prepareDocument.ts b/src/document/prepareDocument.ts new file mode 100644 index 00000000..c529e9f9 --- /dev/null +++ b/src/document/prepareDocument.ts @@ -0,0 +1,76 @@ +import {dispatchDOMEvent} from '../event' +import {isElementType} from '../utils' +import { + prepareRangeTextInterceptor, + prepareSelectionInterceptor, + prepareValueInterceptor, +} from './interceptor' +import {clearInitialValue, getInitialValue} from './UI' + +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 Element + + 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 (initialValue !== undefined) { + if (el.value !== initialValue) { + dispatchDOMEvent(el, 'change') + } + clearInitialValue(el) + } + }, + { + capture: true, + passive: true, + }, + ) + + document[isPrepared] = isPrepared +} + +function prepareElement(el: Element) { + if (el[isPrepared]) { + return + } + + if (isElementType(el, ['input', 'textarea'])) { + prepareValueInterceptor(el) + prepareSelectionInterceptor(el) + prepareRangeTextInterceptor(el) + } + + el[isPrepared] = isPrepared +} diff --git a/src/document/setRangeText.ts b/src/document/setRangeText.ts deleted file mode 100644 index 3b92af52..00000000 --- a/src/document/setRangeText.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {prepareInterceptor} from './interceptor' -import {setUISelectionClean} from './selection' -import {setUIValueClean} from './value' - -export function prepareRangeTextInterceptor( - element: HTMLInputElement | HTMLTextAreaElement, -) { - prepareInterceptor( - element, - 'setRangeText', - function interceptorImpl(...realArgs) { - return { - realArgs, - then: () => { - setUIValueClean(element) - setUISelectionClean(element) - }, - } - }, - ) -} diff --git a/src/document/trackValue.ts b/src/document/trackValue.ts new file mode 100644 index 00000000..55931eeb --- /dev/null +++ b/src/document/trackValue.ts @@ -0,0 +1,85 @@ +import {getWindow} from '../utils' +import {hasUISelection, setUISelection, setUIValueClean} from './UI' + +const TrackChanges = Symbol('Track programmatic changes for React workaround') + +declare global { + interface Window { + REACT_VERSION?: number + } + interface Element { + [TrackChanges]?: { + previousValue?: string + tracked?: string[] + nextValue?: string + } + } +} + +// 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 with React@17, +// the changes are rolled back before the state update is applied. +// This results in a reset cursor. +// There might be a better way to work around if we figure out +// why the batched update is executed differently in our test environment. + +function isReact17Element(element: Element) { + return ( + Object.getOwnPropertyNames(element).some(k => k.startsWith('__react')) && + getWindow(element).REACT_VERSION === 17 + ) +} + +export function startTrackValue( + element: HTMLInputElement | HTMLTextAreaElement, +) { + if (!isReact17Element(element)) { + return + } + + element[TrackChanges] = { + previousValue: String(element.value), + tracked: [], + } +} + +export function trackOrSetValue( + element: HTMLInputElement | HTMLTextAreaElement, + v: string, +) { + element[TrackChanges]?.tracked?.push(v) + + if (!element[TrackChanges]) { + setUIValueClean(element) + setUISelection(element, {focusOffset: v.length}) + } +} + +export function commitValueAfterInput( + element: HTMLInputElement | HTMLTextAreaElement, + cursorOffset: number, +) { + const changes = element[TrackChanges] + + element[TrackChanges] = undefined + + if (!changes?.tracked?.length) { + return + } + + const isJustReactStateUpdate = + changes.tracked.length === 2 && + changes.tracked[0] === changes.previousValue && + changes.tracked[1] === element.value + + if (!isJustReactStateUpdate) { + setUIValueClean(element) + } + + if (hasUISelection(element)) { + setUISelection(element, { + focusOffset: isJustReactStateUpdate ? cursorOffset : element.value.length, + }) + } +} diff --git a/src/document/value.ts b/src/document/value.ts deleted file mode 100644 index 105e4189..00000000 --- a/src/document/value.ts +++ /dev/null @@ -1,172 +0,0 @@ -import {getWindow, isElementType} from '../utils' -import {prepareInterceptor} from './interceptor' -import {hasUISelection, setUISelection} from './selection' - -const UIValue = Symbol('Displayed value in UI') -const InitialValue = Symbol('Initial value to compare on blur') -const TrackChanges = Symbol('Track programmatic changes for React workaround') - -type Value = { - [UIValue]?: typeof UIValue - toString(): string -} - -declare global { - interface Window { - REACT_VERSION?: number - } - interface Element { - [UIValue]?: string - [InitialValue]?: string - [TrackChanges]?: { - previousValue?: string - tracked?: string[] - nextValue?: string - } - } -} - -function valueInterceptor( - this: HTMLInputElement | HTMLTextAreaElement, - v: Value | string, -) { - const isUI = typeof v === 'object' && v[UIValue] - - if (isUI) { - this[UIValue] = String(v) - startTrackValue(this) - } - - return { - applyNative: !!isUI, - realArgs: sanitizeValue(this, v), - then: isUI ? undefined : () => trackOrSetValue(this, String(v)), - } -} - -function sanitizeValue( - element: HTMLInputElement | HTMLTextAreaElement, - v: Value | string, -) { - // Workaround for JSDOM - if ( - isElementType(element, 'input', {type: 'number'}) && - String(v) !== '' && - !Number.isNaN(Number(v)) - ) { - // Setting value to "1." results in `null` in JSDOM - return String(Number(v)) - } - return String(v) -} - -export function prepareValueInterceptor( - element: HTMLInputElement | HTMLTextAreaElement, -) { - prepareInterceptor(element, 'value', valueInterceptor) -} - -export function setUIValue( - element: HTMLInputElement | HTMLTextAreaElement, - value: string, -) { - if (element[InitialValue] === undefined) { - element[InitialValue] = element.value - } - - element.value = { - [UIValue]: UIValue, - toString: () => value, - } as unknown as string -} - -export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) { - return element[UIValue] === undefined - ? element.value - : String(element[UIValue]) -} - -/** Flag the IDL value as clean. This does not change the value.*/ -export function setUIValueClean( - element: HTMLInputElement | HTMLTextAreaElement, -) { - element[UIValue] = undefined -} - -export function clearInitialValue( - element: HTMLInputElement | HTMLTextAreaElement, -) { - element[InitialValue] = undefined -} - -export function getInitialValue( - element: HTMLInputElement | HTMLTextAreaElement, -) { - return element[InitialValue] -} - -// 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 with React@17, -// the changes are rolled back before the state update is applied. -// This results in a reset cursor. -// There might be a better way to work around if we figure out -// why the batched update is executed differently in our test environment. - -function isReact17Element(element: Element) { - return ( - Object.getOwnPropertyNames(element).some(k => k.startsWith('__react')) && - getWindow(element).REACT_VERSION === 17 - ) -} - -function startTrackValue(element: HTMLInputElement | HTMLTextAreaElement) { - if (!isReact17Element(element)) { - return - } - - element[TrackChanges] = { - previousValue: String(element.value), - tracked: [], - } -} - -function trackOrSetValue( - element: HTMLInputElement | HTMLTextAreaElement, - v: string, -) { - element[TrackChanges]?.tracked?.push(v) - - if (!element[TrackChanges]) { - setUIValueClean(element) - setUISelection(element, {focusOffset: v.length}) - } -} - -export function commitValueAfterInput( - element: HTMLInputElement | HTMLTextAreaElement, - cursorOffset: number, -) { - const changes = element[TrackChanges] - - element[TrackChanges] = undefined - - if (!changes?.tracked?.length) { - return - } - - const isJustReactStateUpdate = - changes.tracked.length === 2 && - changes.tracked[0] === changes.previousValue && - changes.tracked[1] === element.value - - if (!isJustReactStateUpdate) { - setUIValueClean(element) - } - - if (hasUISelection(element)) { - setUISelection(element, { - focusOffset: isJustReactStateUpdate ? cursorOffset : element.value.length, - }) - } -} diff --git a/src/event/behavior/click.ts b/src/event/behavior/click.ts index ae41843a..c9d6a4b6 100644 --- a/src/event/behavior/click.ts +++ b/src/event/behavior/click.ts @@ -1,33 +1,26 @@ -import { - blur, - cloneEvent, - focus, - getWindow, - isElementType, - isFocusable, -} from '../../utils' -import {dispatchEvent} from '../dispatchEvent' +import {cloneEvent, getWindow, isElementType, isFocusable} from '../../utils' +import {blurElement, focusElement} from '../focus' import {behavior} from './registry' -behavior.click = (event, target, config) => { +behavior.click = (event, target, instance) => { const context = target.closest('button,input,label,select,textarea') const control = context && isElementType(context, 'label') && context.control if (control) { return () => { if (isFocusable(control)) { - focus(control) + focusElement(control) } - dispatchEvent(config, control, cloneEvent(event)) + instance.dispatchEvent(control, cloneEvent(event)) } } else if (isElementType(target, 'input', {type: 'file'})) { return () => { // blur fires when the file selector pops up - blur(target) + blurElement(target) target.dispatchEvent(new (getWindow(target).Event)('fileDialog')) // focus fires after the file selector has been closed - focus(target) + focusElement(target) } } } diff --git a/src/event/behavior/cut.ts b/src/event/behavior/cut.ts index 86f237df..d877dea3 100644 --- a/src/event/behavior/cut.ts +++ b/src/event/behavior/cut.ts @@ -1,10 +1,11 @@ -import {input, isEditable} from '../../utils' +import {isEditable} from '../../utils' +import {input} from '../input' import {behavior} from './registry' -behavior.cut = (event, target, config) => { +behavior.cut = (event, target, instance) => { return () => { if (isEditable(target)) { - input(config, target, '', 'deleteByCut') + input(instance, target, '', 'deleteByCut') } } } diff --git a/src/event/behavior/keydown.ts b/src/event/behavior/keydown.ts index 969446b9..6927deef 100644 --- a/src/event/behavior/keydown.ts +++ b/src/event/behavior/keydown.ts @@ -1,68 +1,65 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import {setUISelection} from '../../document' +import {getUIValue, setUISelection, getValueOrTextContent} from '../../document' import { - focus, getTabDestination, - getValue, hasOwnSelection, - input, isContentEditable, isEditable, isElementType, - moveSelection, - selectAll, - setSelectionRange, - walkRadio, } from '../../utils' +import {focusElement} from '../focus' +import {input} from '../input' +import {moveSelection, selectAll, setSelectionRange} from '../selection' +import {walkRadio} from '../radio' import {BehaviorPlugin} from '.' import {behavior} from './registry' -behavior.keydown = (event, target, config) => { +behavior.keydown = (event, target, instance) => { return ( - keydownBehavior[event.key]?.(event, target, config) ?? - combinationBehavior(event, target, config) + keydownBehavior[event.key]?.(event, target, instance) ?? + combinationBehavior(event, target, instance) ) } const keydownBehavior: { [key: string]: BehaviorPlugin<'keydown'> | undefined } = { - ArrowDown: (event, target, config) => { + ArrowDown: (event, target, instance) => { /* istanbul ignore else */ if (isElementType(target, 'input', {type: 'radio'} as const)) { - return () => walkRadio(config, target, -1) + return () => walkRadio(instance, target, -1) } }, - ArrowLeft: (event, target, config) => { + ArrowLeft: (event, target, instance) => { if (isElementType(target, 'input', {type: 'radio'} as const)) { - return () => walkRadio(config, target, -1) + return () => walkRadio(instance, target, -1) } return () => moveSelection(target, -1) }, - ArrowRight: (event, target, config) => { + ArrowRight: (event, target, instance) => { if (isElementType(target, 'input', {type: 'radio'} as const)) { - return () => walkRadio(config, target, 1) + return () => walkRadio(instance, target, 1) } return () => moveSelection(target, 1) }, - ArrowUp: (event, target, config) => { + ArrowUp: (event, target, instance) => { /* istanbul ignore else */ if (isElementType(target, 'input', {type: 'radio'} as const)) { - return () => walkRadio(config, target, 1) + return () => walkRadio(instance, target, 1) } }, - Backspace: (event, target, config) => { + Backspace: (event, target, instance) => { if (isEditable(target)) { return () => { - input(config, target, '', 'deleteContentBackward') + input(instance, target, '', 'deleteContentBackward') } } }, - Delete: (event, target, config) => { + Delete: (event, target, instance) => { if (isEditable(target)) { return () => { - input(config, target, '', 'deleteContentForward') + input(instance, target, '', 'deleteContentForward') } } }, @@ -72,7 +69,8 @@ const keydownBehavior: { isContentEditable(target) ) { return () => { - const newPos = getValue(target)?.length ?? /* istanbul ignore next */ 0 + const newPos = + getValueOrTextContent(target)?.length ?? /* istanbul ignore next */ 0 setSelectionRange(target, newPos, newPos) } } @@ -90,7 +88,7 @@ const keydownBehavior: { PageDown: (event, target) => { if (isElementType(target, ['input'])) { return () => { - const newPos = getValue(target).length + const newPos = getUIValue(target).length setSelectionRange(target, newPos, newPos) } } @@ -102,13 +100,13 @@ const keydownBehavior: { } } }, - Tab: (event, target, config) => { + Tab: (event, target, instance) => { return () => { const dest = getTabDestination( target, - config.system.keyboard.modifiers.Shift, + instance.system.keyboard.modifiers.Shift, ) - focus(dest) + focusElement(dest) if (hasOwnSelection(dest)) { setUISelection(dest, { anchorOffset: 0, @@ -122,9 +120,9 @@ const keydownBehavior: { const combinationBehavior: BehaviorPlugin<'keydown'> = ( event, target, - config, + instance, ) => { - if (event.code === 'KeyA' && config.system.keyboard.modifiers.Control) { + if (event.code === 'KeyA' && instance.system.keyboard.modifiers.Control) { return () => selectAll(target) } } diff --git a/src/event/behavior/keypress.ts b/src/event/behavior/keypress.ts index a198dc44..1c12e7f6 100644 --- a/src/event/behavior/keypress.ts +++ b/src/event/behavior/keypress.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import {dispatchUIEvent} from '..' -import {input, isContentEditable, isEditable, isElementType} from '../../utils' +import {isContentEditable, isEditable, isElementType} from '../../utils' +import {input} from '../input' import {behavior} from './registry' -behavior.keypress = (event, target, config) => { +behavior.keypress = (event, target, instance) => { if (event.key === 'Enter') { if ( isElementType(target, 'button') || @@ -13,7 +13,7 @@ behavior.keypress = (event, target, config) => { (isElementType(target, 'a') && Boolean(target.href)) ) { return () => { - dispatchUIEvent(config, target, 'click') + instance.dispatchUIEvent(target, 'click') } } else if (isElementType(target, 'input')) { const form = target.form @@ -21,13 +21,13 @@ behavior.keypress = (event, target, config) => { 'input[type="submit"], button:not([type]), button[type="submit"]', ) if (submit) { - return () => dispatchUIEvent(config, submit, 'click') + return () => instance.dispatchUIEvent(submit, 'click') } else if ( form && SubmitSingleInputOnEnter.includes(target.type) && form.querySelectorAll('input').length === 1 ) { - return () => dispatchUIEvent(config, form, 'submit') + return () => instance.dispatchUIEvent(form, 'submit') } else { return } @@ -37,13 +37,13 @@ behavior.keypress = (event, target, config) => { if (isEditable(target)) { const inputType = event.key === 'Enter' - ? isContentEditable(target) && !config.system.keyboard.modifiers.Shift + ? isContentEditable(target) && !instance.system.keyboard.modifiers.Shift ? 'insertParagraph' : 'insertLineBreak' : 'insertText' const inputData = event.key === 'Enter' ? '\n' : event.key - return () => input(config, target, inputData, inputType) + return () => input(instance, target, inputData, inputType) } } diff --git a/src/event/behavior/keyup.ts b/src/event/behavior/keyup.ts index 3c06f75d..2dd6b0ac 100644 --- a/src/event/behavior/keyup.ts +++ b/src/event/behavior/keyup.ts @@ -1,20 +1,19 @@ /* 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) +behavior.keyup = (event, target, instance) => { + return keyupBehavior[event.key]?.(event, target, instance) } const keyupBehavior: { [key: string]: BehaviorPlugin<'keyup'> | undefined } = { - ' ': (event, target, config) => { + ' ': (event, target, instance) => { if (isClickableInput(target)) { - return () => dispatchUIEvent(config, target, 'click') + return () => instance.dispatchUIEvent(target, 'click') } }, } diff --git a/src/event/behavior/paste.ts b/src/event/behavior/paste.ts index 45c95511..092e7247 100644 --- a/src/event/behavior/paste.ts +++ b/src/event/behavior/paste.ts @@ -1,12 +1,13 @@ -import {input, isEditable} from '../../utils' +import {isEditable} from '../../utils' +import {input} from '../input' import {behavior} from './registry' -behavior.paste = (event, target, config) => { +behavior.paste = (event, target, instance) => { if (isEditable(target)) { return () => { const insertData = event.clipboardData?.getData('text') if (insertData) { - input(config, target, insertData, 'insertFromPaste') + input(instance, target, insertData, 'insertFromPaste') } } } diff --git a/src/event/behavior/registry.ts b/src/event/behavior/registry.ts index a58327e1..586d0231 100644 --- a/src/event/behavior/registry.ts +++ b/src/event/behavior/registry.ts @@ -1,11 +1,11 @@ -import {Config} from '../../setup' +import type {Instance} from '../../setup' import {EventType} from '../types' export interface BehaviorPlugin { ( event: DocumentEventMap[Type], target: Element, - config: Config, + instance: Instance, ): // eslint-disable-next-line @typescript-eslint/no-invalid-void-type void | (() => void) } diff --git a/src/event/dispatchEvent.ts b/src/event/dispatchEvent.ts index 8619173f..c3ea6fc1 100644 --- a/src/event/dispatchEvent.ts +++ b/src/event/dispatchEvent.ts @@ -1,10 +1,31 @@ -import {Config} from '../setup' -import {EventType} from './types' +import type {Instance} from '../setup' +import {EventType, EventTypeInit} from './types' import {behavior, BehaviorPlugin} from './behavior' import {wrapEvent} from './wrapEvent' +import {isKeyboardEvent, isMouseEvent} from './eventMap' +import {createEvent} from './createEvent' + +export function dispatchUIEvent( + this: Instance, + target: Element, + type: K, + init?: EventTypeInit, + preventDefault: boolean = false, +) { + if (isMouseEvent(type) || isKeyboardEvent(type)) { + init = { + ...init, + ...this.system.getUIEventModifiers(), + } as EventTypeInit + } + + const event = createEvent(type, target, init) + + return dispatchEvent.call(this, target, event, preventDefault) +} export function dispatchEvent( - config: Config, + this: Instance, target: Element, event: Event, preventDefault: boolean = false, @@ -15,7 +36,7 @@ export function dispatchEvent( : (behavior[type] as BehaviorPlugin | undefined)?.( event, target, - config, + this, ) if (behaviorImplementation) { @@ -41,3 +62,12 @@ export function dispatchEvent( return wrapEvent(() => target.dispatchEvent(event), target) } + +export function dispatchDOMEvent( + target: Element, + type: K, + init?: EventTypeInit, +) { + const event = createEvent(type, target, init) + wrapEvent(() => target.dispatchEvent(event), target) +} diff --git a/src/event/focus.ts b/src/event/focus.ts new file mode 100644 index 00000000..d21b4e40 --- /dev/null +++ b/src/event/focus.ts @@ -0,0 +1,30 @@ +import {findClosest, getActiveElement, isFocusable} from '../utils' +import {updateSelectionOnFocus} from './selection' +import {wrapEvent} from './wrapEvent' + +/** + * Focus closest focusable element. + */ +export function focusElement(element: Element) { + const target = findClosest(element, isFocusable) + + const activeElement = getActiveElement(element.ownerDocument) + if ((target ?? element.ownerDocument.body) === activeElement) { + return + } else if (target) { + wrapEvent(() => target.focus(), element) + } else { + wrapEvent(() => (activeElement as HTMLElement | null)?.blur(), element) + } + + updateSelectionOnFocus(target ?? element.ownerDocument.body) +} + +export function blurElement(element: Element) { + if (!isFocusable(element)) return + + const wasActive = getActiveElement(element.ownerDocument) === element + if (!wasActive) return + + wrapEvent(() => element.blur(), element) +} diff --git a/src/event/index.ts b/src/event/index.ts index 9cce9b19..d2a9b70f 100644 --- a/src/event/index.ts +++ b/src/event/index.ts @@ -1,30 +1,14 @@ -import {Config} from '../setup' -import {createEvent} from './createEvent' -import {dispatchEvent} from './dispatchEvent' -import {isKeyboardEvent, isMouseEvent} from './eventMap' -import {EventType, EventTypeInit, PointerCoords} from './types' +import {EventType, PointerCoords} from './types' export type {EventType, PointerCoords} -export function dispatchUIEvent( - config: Config, - target: Element, - type: K, - init?: EventTypeInit, - preventDefault: boolean = false, -) { - if (isMouseEvent(type) || isKeyboardEvent(type)) { - init = { - ...init, - ...config.system.getUIEventModifiers(), - } as EventTypeInit - } - - const event = createEvent(type, target, init) - - return dispatchEvent(config, target, event, preventDefault) -} - -export function bindDispatchUIEvent(config: Config) { - return dispatchUIEvent.bind(undefined, config) -} +export {dispatchEvent, dispatchUIEvent, dispatchDOMEvent} from './dispatchEvent' +export {blurElement, focusElement} from './focus' +export {input} from './input' +export type {SelectionRange} from './selection' +export { + isAllSelected, + modifySelectionPerMouseMove, + setSelectionPerMouseDown, + selectAll, +} from './selection' diff --git a/src/utils/edit/input.ts b/src/event/input.ts similarity index 80% rename from src/utils/edit/input.ts rename to src/event/input.ts index 12263c3b..b1d501ae 100644 --- a/src/utils/edit/input.ts +++ b/src/event/input.ts @@ -4,23 +4,19 @@ import { getUIValue, setUIValue, UISelectionRange, -} from '../../document' -import {dispatchUIEvent} from '../../event' -import {Config} from '../../setup' +} from '../document' +import type {Instance} from '../setup' import { - getInputRange, + buildTimeValue, + EditableInputOrTextarea, + getMaxLength, 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 + isValidDateOrTimeValue, + supportsMaxLength, +} from '../utils' +import {getInputRange, setSelection} from './selection' + type DateOrTimeInput = HTMLInputElement & {type: 'date' | 'time'} function isDateOrTime(element: Element): element is DateOrTimeInput { @@ -30,7 +26,7 @@ function isDateOrTime(element: Element): element is DateOrTimeInput { } export function input( - config: Config, + instance: Instance, element: Element, data: string, inputType: string = 'insertText', @@ -44,7 +40,7 @@ export function input( // There is no `beforeinput` event on `date` and `time` input if (!isDateOrTime(element)) { - const unprevented = dispatchUIEvent(config, element, 'beforeinput', { + const unprevented = instance.dispatchUIEvent(element, 'beforeinput', { inputType, data, }) @@ -55,10 +51,10 @@ export function input( } if ('startContainer' in inputRange) { - editContenteditable(config, element, inputRange, data, inputType) + editContenteditable(instance, element, inputRange, data, inputType) } else { editInputElement( - config, + instance, element as EditableInputOrTextarea, inputRange, data, @@ -68,7 +64,7 @@ export function input( } function editContenteditable( - config: Config, + instance: Instance, element: Element, inputRange: Range, data: string, @@ -115,24 +111,27 @@ function editContenteditable( } if (del || data) { - dispatchUIEvent(config, element, 'input', {inputType}) + instance.dispatchUIEvent(element, 'input', {inputType}) } } function editInputElement( - config: Config, + instance: Instance, element: EditableInputOrTextarea, inputRange: UISelectionRange, data: string, inputType: string, ) { let dataToInsert = data - const spaceUntilMaxLength = getSpaceUntilMaxLength(element) - if (spaceUntilMaxLength !== undefined && data.length > 0) { - if (spaceUntilMaxLength > 0) { - dataToInsert = data.substring(0, spaceUntilMaxLength) - } else { - return + if (supportsMaxLength(element)) { + const maxLength = getMaxLength(element) + if (maxLength !== undefined && data.length > 0) { + const spaceUntilMaxLength = maxLength - element.value.length + if (spaceUntilMaxLength > 0) { + dataToInsert = data.substring(0, spaceUntilMaxLength) + } else { + return + } } } @@ -167,12 +166,12 @@ function editInputElement( if (isDateOrTime(element)) { if (isValidDateOrTimeValue(element, newValue)) { - commitInput(config, element, newOffset, {}) - dispatchUIEvent(config, element, 'change') + commitInput(instance, element, newOffset, {}) + instance.dispatchUIEvent(element, 'change') clearInitialValue(element) } } else { - commitInput(config, element, newOffset, { + commitInput(instance, element, newOffset, { data, inputType, }) @@ -227,12 +226,12 @@ function calculateNewValue( } function commitInput( - config: Config, + instance: Instance, element: EditableInputOrTextarea, newOffset: number, inputInit: InputEventInit, ) { - dispatchUIEvent(config, element, 'input', inputInit) + instance.dispatchUIEvent(element, 'input', inputInit) commitValueAfterInput(element, newOffset) } diff --git a/src/utils/edit/walkRadio.ts b/src/event/radio.ts similarity index 69% rename from src/utils/edit/walkRadio.ts rename to src/event/radio.ts index 2a12171a..9c31098a 100644 --- a/src/utils/edit/walkRadio.ts +++ b/src/event/radio.ts @@ -1,11 +1,9 @@ -import {dispatchUIEvent} from '../../event' -import {Config} from '../../setup' -import {focus} from '../focus/focus' -import {getWindow} from '../misc/getWindow' -import {isDisabled} from '../misc/isDisabled' +import type {Instance} from '../setup' +import {getWindow, isDisabled} from '../utils' +import {focusElement} from './focus' export function walkRadio( - config: Config, + instance: Instance, el: HTMLInputElement & {type: 'radio'}, direction: -1 | 1, ) { @@ -28,7 +26,7 @@ export function walkRadio( continue } - focus(group[i]) - dispatchUIEvent(config, group[i], 'click') + focusElement(group[i]) + instance.dispatchUIEvent(group[i], 'click') } } diff --git a/src/event/selection/getInputRange.ts b/src/event/selection/getInputRange.ts new file mode 100644 index 00000000..54f174e4 --- /dev/null +++ b/src/event/selection/getInputRange.ts @@ -0,0 +1,18 @@ +import {UISelectionRange} from '../../document' +import {getTargetTypeAndSelection} from './getTargetTypeAndSelection' + +/** + * Get the range that would be overwritten by input. + */ +export function getInputRange( + focusNode: Node, +): UISelectionRange | Range | undefined { + const typeAndSelection = getTargetTypeAndSelection(focusNode) + + if (typeAndSelection.type === 'input') { + return typeAndSelection.selection + } else if (typeAndSelection.type === 'contenteditable') { + // Multi-range on contenteditable edits the first selection instead of the last + return typeAndSelection.selection?.getRangeAt(0) + } +} diff --git a/src/event/selection/getTargetTypeAndSelection.ts b/src/event/selection/getTargetTypeAndSelection.ts new file mode 100644 index 00000000..7916f3c8 --- /dev/null +++ b/src/event/selection/getTargetTypeAndSelection.ts @@ -0,0 +1,34 @@ +import {getUISelection} from '../../document' +import {getContentEditable, hasOwnSelection} from '../../utils' + +/** + * Determine which selection logic and selection ranges to consider. + */ +export function getTargetTypeAndSelection(node: Node) { + const element = getElement(node) + + if (element && hasOwnSelection(element)) { + return { + type: 'input', + selection: getUISelection(element), + } as const + } + + const selection = element?.ownerDocument.getSelection() + + // It is possible to extend a single-range selection into a contenteditable. + // This results in the range acting like a range outside of contenteditable. + const isCE = + getContentEditable(node) && + selection?.anchorNode && + getContentEditable(selection.anchorNode) + + return { + type: isCE ? 'contenteditable' : 'default', + selection, + } as const +} + +function getElement(node: Node) { + return node.nodeType === 1 ? (node as Element) : node.parentElement +} diff --git a/src/event/selection/index.ts b/src/event/selection/index.ts new file mode 100644 index 00000000..e0a9460d --- /dev/null +++ b/src/event/selection/index.ts @@ -0,0 +1,17 @@ +import type {EditableInputOrTextarea} from '../../utils' + +export {getInputRange} from './getInputRange' +export {modifySelection} from './modifySelection' +export {moveSelection} from './moveSelection' +export {setSelectionPerMouseDown} from './setSelectionPerMouse' +export {modifySelectionPerMouseMove} from './modifySelectionPerMouse' +export {isAllSelected, selectAll} from './selectAll' +export {setSelectionRange} from './setSelectionRange' +export {setSelection} from './setSelection' +export {updateSelectionOnFocus} from './updateSelectionOnFocus' + +export type SelectionRange = { + node: EditableInputOrTextarea + start: number + end: number +} diff --git a/src/event/selection/modifySelection.ts b/src/event/selection/modifySelection.ts new file mode 100644 index 00000000..2a15754e --- /dev/null +++ b/src/event/selection/modifySelection.ts @@ -0,0 +1,29 @@ +import {setUISelection} from '../../document' +import {getTargetTypeAndSelection} from './getTargetTypeAndSelection' + +/** + * Extend/shrink the selection like with Shift+Arrows or Shift+Mouse + */ +export function modifySelection({ + focusNode, + focusOffset, +}: { + focusNode: Node + /** DOM Offset */ + focusOffset: number +}) { + const typeAndSelection = getTargetTypeAndSelection(focusNode) + + if (typeAndSelection.type === 'input') { + return setUISelection( + focusNode as HTMLInputElement, + { + anchorOffset: typeAndSelection.selection.anchorOffset, + focusOffset, + }, + 'modify', + ) + } + + focusNode.ownerDocument?.getSelection()?.extend(focusNode, focusOffset) +} diff --git a/src/event/selection/modifySelectionPerMouse.ts b/src/event/selection/modifySelectionPerMouse.ts new file mode 100644 index 00000000..999458e0 --- /dev/null +++ b/src/event/selection/modifySelectionPerMouse.ts @@ -0,0 +1,54 @@ +import {setUISelection} from '../../document' +import type {SelectionRange} from '.' +import {resolveCaretPosition} from './resolveCaretPosition' + +export function modifySelectionPerMouseMove( + selectionRange: Range | SelectionRange, + { + document, + target, + node, + offset, + }: { + document: Document + target: Element + node?: Node + offset?: number + }, +) { + const selectionFocus = resolveCaretPosition({target, node, offset}) + + if ('node' in selectionRange) { + // When the mouse is dragged outside of an input/textarea, + // the selection is extended to the beginning or end of the input + // depending on pointer position. + // TODO: extend selection according to pointer position + /* istanbul ignore else */ + if (selectionFocus.node === selectionRange.node) { + const anchorOffset = + selectionFocus.offset < selectionRange.start + ? selectionRange.end + : selectionRange.start + const focusOffset = + selectionFocus.offset > selectionRange.end || + selectionFocus.offset < selectionRange.start + ? selectionFocus.offset + : selectionRange.end + + setUISelection(selectionRange.node, {anchorOffset, focusOffset}) + } + } else { + const range = selectionRange.cloneRange() + + const cmp = range.comparePoint(selectionFocus.node, selectionFocus.offset) + if (cmp < 0) { + range.setStart(selectionFocus.node, selectionFocus.offset) + } else if (cmp > 0) { + range.setEnd(selectionFocus.node, selectionFocus.offset) + } + + const selection = document.getSelection() + selection?.removeAllRanges() + selection?.addRange(range.cloneRange()) + } +} diff --git a/src/event/selection/moveSelection.ts b/src/event/selection/moveSelection.ts new file mode 100644 index 00000000..6654757a --- /dev/null +++ b/src/event/selection/moveSelection.ts @@ -0,0 +1,46 @@ +import {getUISelection} from '../../document' +import {getNextCursorPosition, hasOwnSelection} from '../../utils' +import {setSelection} from './setSelection' + +/** + * 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() + + if (!selection?.focusNode) { + return + } + + if (selection.isCollapsed) { + const nextPosition = getNextCursorPosition( + selection.focusNode, + selection.focusOffset, + direction, + ) + if (nextPosition) { + setSelection({ + focusNode: nextPosition.node, + focusOffset: nextPosition.offset, + }) + } + } else { + selection[direction < 0 ? 'collapseToStart' : 'collapseToEnd']() + } + } +} diff --git a/src/utils/focus/resolveCaretPosition.ts b/src/event/selection/resolveCaretPosition.ts similarity index 98% rename from src/utils/focus/resolveCaretPosition.ts rename to src/event/selection/resolveCaretPosition.ts index 3299209b..a2ec74aa 100644 --- a/src/utils/focus/resolveCaretPosition.ts +++ b/src/event/selection/resolveCaretPosition.ts @@ -1,5 +1,5 @@ import {getUIValue} from '../../document' -import {hasOwnSelection} from '..' +import {hasOwnSelection} from '../../utils' export function resolveCaretPosition({ target, diff --git a/src/utils/focus/selectAll.ts b/src/event/selection/selectAll.ts similarity index 68% rename from src/utils/focus/selectAll.ts rename to src/event/selection/selectAll.ts index e2b46ec9..ba0d5b03 100644 --- a/src/utils/focus/selectAll.ts +++ b/src/event/selection/selectAll.ts @@ -1,17 +1,13 @@ import {getUISelection, getUIValue} from '../../document' -import {getContentEditable} from '../edit/isContentEditable' -import {editableInputTypes} from '../edit/isEditable' -import {isElementType} from '../misc/isElementType' -import {setSelection} from './selection' +import {hasOwnSelection} from '../../utils' +import {getContentEditable} from '../../utils/edit/isContentEditable' +import {setSelection} from './setSelection' /** * Expand a selection like the browser does when pressing Ctrl+A. */ export function selectAll(target: Element): void { - if ( - isElementType(target, 'textarea') || - (isElementType(target, 'input') && target.type in editableInputTypes) - ) { + if (hasOwnSelection(target)) { return setSelection({ focusNode: target, anchorOffset: 0, @@ -28,10 +24,7 @@ export function selectAll(target: Element): void { } export function isAllSelected(target: Element): boolean { - if ( - isElementType(target, 'textarea') || - (isElementType(target, 'input') && target.type in editableInputTypes) - ) { + if (hasOwnSelection(target)) { return ( getUISelection(target).startOffset === 0 && getUISelection(target).endOffset === getUIValue(target).length diff --git a/src/event/selection/setSelection.ts b/src/event/selection/setSelection.ts new file mode 100644 index 00000000..ebd52b8f --- /dev/null +++ b/src/event/selection/setSelection.ts @@ -0,0 +1,31 @@ +import {setUISelection} from '../../document' +import {getTargetTypeAndSelection} from './getTargetTypeAndSelection' + +/** + * Set the selection + */ +export function setSelection({ + focusNode, + focusOffset, + anchorNode = focusNode, + anchorOffset = focusOffset, +}: { + anchorNode?: Node + /** DOM offset */ + anchorOffset?: number + focusNode: Node + focusOffset: number +}) { + const typeAndSelection = getTargetTypeAndSelection(focusNode) + + if (typeAndSelection.type === 'input') { + return setUISelection(focusNode as HTMLInputElement, { + anchorOffset, + focusOffset, + }) + } + + anchorNode.ownerDocument + ?.getSelection() + ?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) +} diff --git a/src/event/selection/setSelectionPerMouse.ts b/src/event/selection/setSelectionPerMouse.ts new file mode 100644 index 00000000..95a184c9 --- /dev/null +++ b/src/event/selection/setSelectionPerMouse.ts @@ -0,0 +1,111 @@ +import {getUIValue, setUISelection} from '../../document' +import {hasNoSelection, hasOwnSelection} from '../../utils' +import type {SelectionRange} from '.' +import {resolveCaretPosition} from './resolveCaretPosition' + +export function setSelectionPerMouseDown({ + document, + target, + clickCount, + node, + offset, +}: { + document: Document + target: Element + clickCount: number + node?: Node + offset?: number +}): Range | SelectionRange | undefined { + if (hasNoSelection(target)) { + return + } + const targetHasOwnSelection = hasOwnSelection(target) + + // On non-input elements the text selection per multiple click + // can extend beyond the target boundaries. + // The exact mechanism what is considered in the same line is unclear. + // Looks it might be every inline element. + // TODO: Check what might be considered part of the same line of text. + const text = String( + targetHasOwnSelection ? getUIValue(target) : target.textContent, + ) + + const [start, end] = node + ? // As offset is describing a DOMOffset it is non-trivial to determine + // which elements might be considered in the same line of text. + // TODO: support expanding initial range on multiple clicks if node is given + [offset, offset] + : getTextRange(text, offset, clickCount) + + // TODO: implement modifying selection per shift/ctrl+mouse + if (targetHasOwnSelection) { + setUISelection(target, { + anchorOffset: start ?? text.length, + focusOffset: end ?? text.length, + }) + return { + node: target, + start: start ?? 0, + end: end ?? text.length, + } + } else { + const {node: startNode, offset: startOffset} = resolveCaretPosition({ + target, + node, + offset: start, + }) + const {node: endNode, offset: endOffset} = resolveCaretPosition({ + target, + node, + offset: end, + }) + + const range = target.ownerDocument.createRange() + try { + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + } catch (e: unknown) { + throw new Error('The given offset is out of bounds.') + } + + const selection = document.getSelection() + selection?.removeAllRanges() + selection?.addRange(range.cloneRange()) + + return range + } +} + +function getTextRange( + text: string, + pos: number | undefined, + clickCount: number, +) { + if (clickCount % 3 === 1 || text.length === 0) { + return [pos, pos] + } + + const textPos = pos ?? text.length + if (clickCount % 3 === 2) { + return [ + textPos - + (text.substr(0, pos).match(/(\w+|\s+|\W)?$/) as RegExpMatchArray)[0] + .length, + pos === undefined + ? pos + : pos + + (text.substr(pos).match(/^(\w+|\s+|\W)?/) as RegExpMatchArray)[0] + .length, + ] + } + + // triple click + return [ + textPos - + (text.substr(0, pos).match(/[^\r\n]*$/) as RegExpMatchArray)[0].length, + pos === undefined + ? pos + : pos + + (text.substr(pos).match(/^[^\r\n]*/) as RegExpMatchArray)[0].length, + ] +} diff --git a/src/event/selection/setSelectionRange.ts b/src/event/selection/setSelectionRange.ts new file mode 100644 index 00000000..7d420f8b --- /dev/null +++ b/src/event/selection/setSelectionRange.ts @@ -0,0 +1,35 @@ +import {hasOwnSelection, isContentEditable} from '../../utils' +import {setSelection} from './setSelection' + +/** + * Backward-compatible selection. + * + * Handles input elements and contenteditable if it only contains a single text node. + */ +export function setSelectionRange( + element: Element, + anchorOffset: number, + focusOffset: number, +) { + if (hasOwnSelection(element)) { + return setSelection({ + focusNode: element, + anchorOffset, + focusOffset, + }) + } + + /* istanbul ignore else */ + if (isContentEditable(element) && element.firstChild?.nodeType === 3) { + return setSelection({ + focusNode: element.firstChild, + anchorOffset, + focusOffset, + }) + } + + /* istanbul ignore next */ + throw new Error( + 'Not implemented. The result of this interaction is unreliable.', + ) +} diff --git a/src/event/selection/updateSelectionOnFocus.ts b/src/event/selection/updateSelectionOnFocus.ts new file mode 100644 index 00000000..5d235aa1 --- /dev/null +++ b/src/event/selection/updateSelectionOnFocus.ts @@ -0,0 +1,36 @@ +import {getContentEditable, hasOwnSelection} from '../../utils' + +/** + * Reset the Document Selection when moving focus into an element + * with own selection implementation. + */ +export function updateSelectionOnFocus(element: Element) { + const selection = element.ownerDocument.getSelection() + + /* istanbul ignore if */ + if (!selection?.focusNode) { + return + } + + // If the focus moves inside an element with own selection implementation, + // the document selection will be this element. + // But if the focused element is inside a contenteditable, + // 1) a collapsed selection will be retained. + // 2) other selections will be replaced by a cursor + // 2.a) at the start of the first child if it is a text node + // 2.b) at the start of the contenteditable. + if (hasOwnSelection(element)) { + const contenteditable = getContentEditable(selection.focusNode) + if (contenteditable) { + if (!selection.isCollapsed) { + const focusNode = + contenteditable.firstChild?.nodeType === 3 + ? contenteditable.firstChild + : contenteditable + selection.setBaseAndExtent(focusNode, 0, focusNode, 0) + } + } else { + selection.setBaseAndExtent(element, 0, element, 0) + } + } +} diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index aba440a7..124508ba 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -1,5 +1,5 @@ -import {Config, Instance} from '../setup' -import {keyboardKey} from '../system/keyboard' +import type {Instance} from '../setup' +import type {keyboardKey} from '../system/keyboard' import {wait} from '../utils' import {parseKeyDef} from './parseKeyDef' @@ -11,44 +11,44 @@ interface KeyboardAction { } export async function keyboard(this: Instance, text: string): Promise { - const actions: KeyboardAction[] = parseKeyDef(this[Config].keyboardMap, text) + const actions: KeyboardAction[] = parseKeyDef(this.config.keyboardMap, text) for (let i = 0; i < actions.length; i++) { - await wait(this[Config]) + await wait(this.config) - await keyboardAction(this[Config], actions[i]) + await keyboardAction(this, actions[i]) } } async function keyboardAction( - config: Config, + instance: Instance, {keyDef, releasePrevious, releaseSelf, repeat}: KeyboardAction, ) { - const {system} = config + const {system} = instance // Release the key automatically if it was pressed before. if (system.keyboard.isKeyPressed(keyDef)) { - await system.keyboard.keyup(config, keyDef) + await system.keyboard.keyup(instance, keyDef) } if (!releasePrevious) { for (let i = 1; i <= repeat; i++) { - await system.keyboard.keydown(config, keyDef) + await system.keyboard.keydown(instance, keyDef) if (i < repeat) { - await wait(config) + await wait(instance.config) } } // Release the key only on the last iteration on `state.repeatKey`. if (releaseSelf) { - await system.keyboard.keyup(config, keyDef) + await system.keyboard.keyup(instance, keyDef) } } } -export async function releaseAllKeys(config: Config) { - for (const k of config.system.keyboard.getPressedKeys()) { - await config.system.keyboard.keyup(config, k) +export async function releaseAllKeys(instance: Instance) { + for (const k of instance.system.keyboard.getPressedKeys()) { + await instance.system.keyboard.keyup(instance, k) } } diff --git a/src/options.ts b/src/options.ts index 81f01a29..a1ba67a4 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,3 @@ -import {defaultKeyMap as defaultKeyboardMap} from './keyboard/keyMap' -import {defaultKeyMap as defaultPointerMap} from './pointer/keyMap' import type {keyboardKey} from './system/keyboard' import type {pointerKey} from './system/pointer' @@ -128,29 +126,3 @@ export interface Options { */ advanceTimers?: ((delay: number) => Promise) | ((delay: number) => void) } - -/** - * Default options applied when API is called per `userEvent.anyApi()` - */ -export const defaultOptionsDirect: Required = { - applyAccept: true, - autoModify: true, - delay: 0, - document: globalThis.document, - keyboardMap: defaultKeyboardMap, - pointerMap: defaultPointerMap, - pointerEventsCheck: PointerEventsCheckLevel.EachApiCall, - skipAutoClose: false, - skipClick: false, - skipHover: false, - writeToClipboard: false, - advanceTimers: () => Promise.resolve(), -} - -/** - * Default options applied when API is called per `userEvent().anyApi()` - */ -export const defaultOptionsSetup: Required = { - ...defaultOptionsDirect, - writeToClipboard: true, -} diff --git a/src/pointer/index.ts b/src/pointer/index.ts index c8354aff..9d67d813 100644 --- a/src/pointer/index.ts +++ b/src/pointer/index.ts @@ -1,6 +1,6 @@ -import {PointerCoords} from '../event' -import {Config, Instance} from '../setup' -import {pointerKey, PointerPosition} from '../system/pointer' +import type {PointerCoords} from '../event' +import type {Instance} from '../setup' +import type {pointerKey, PointerPosition} from '../system/pointer' import {ApiLevel, setLevelRef, wait} from '../utils' import {parseKeyDef} from './parseKeyDef' @@ -37,7 +37,7 @@ export async function pointer( this: Instance, input: PointerInput, ): Promise { - const {pointerMap} = this[Config] + const {pointerMap} = this.config const actions: PointerAction[] = [] ;(Array.isArray(input) ? input : [input]).forEach(actionInput => { @@ -56,26 +56,26 @@ export async function pointer( }) for (let i = 0; i < actions.length; i++) { - await wait(this[Config]) + await wait(this.config) - await pointerAction(this[Config], actions[i]) + await pointerAction(this, actions[i]) } - this[Config].system.pointer.resetClickCount() + this.system.pointer.resetClickCount() } -async function pointerAction(config: Config, action: PointerAction) { +async function pointerAction(instance: Instance, action: PointerAction) { const pointerName = 'pointerName' in action && action.pointerName ? action.pointerName : 'keyDef' in action - ? config.system.pointer.getPointerName(action.keyDef) + ? instance.system.pointer.getPointerName(action.keyDef) : 'mouse' const previousPosition = - config.system.pointer.getPreviousPosition(pointerName) + instance.system.pointer.getPreviousPosition(pointerName) const position: PointerPosition = { - target: action.target ?? getPrevTarget(config, previousPosition), + target: action.target ?? getPrevTarget(instance, previousPosition), coords: action.coords ?? previousPosition?.coords, caret: { node: @@ -90,23 +90,23 @@ async function pointerAction(config: Config, action: PointerAction) { } if ('keyDef' in action) { - if (config.system.pointer.isKeyPressed(action.keyDef)) { - setLevelRef(config, ApiLevel.Trigger) - await config.system.pointer.release(config, action.keyDef, position) + if (instance.system.pointer.isKeyPressed(action.keyDef)) { + setLevelRef(instance, ApiLevel.Trigger) + await instance.system.pointer.release(instance, action.keyDef, position) } if (!action.releasePrevious) { - setLevelRef(config, ApiLevel.Trigger) - await config.system.pointer.press(config, action.keyDef, position) + setLevelRef(instance, ApiLevel.Trigger) + await instance.system.pointer.press(instance, action.keyDef, position) if (action.releaseSelf) { - setLevelRef(config, ApiLevel.Trigger) - await config.system.pointer.release(config, action.keyDef, position) + setLevelRef(instance, ApiLevel.Trigger) + await instance.system.pointer.release(instance, action.keyDef, position) } } } else { - setLevelRef(config, ApiLevel.Trigger) - await config.system.pointer.move(config, pointerName, position) + setLevelRef(instance, ApiLevel.Trigger) + await instance.system.pointer.move(instance, pointerName, position) } } @@ -114,12 +114,12 @@ function hasCaretPosition(action: PointerAction) { return !!(action.target ?? action.node ?? action.offset !== undefined) } -function getPrevTarget(config: Config, position?: PointerPosition) { +function getPrevTarget(instance: Instance, position?: PointerPosition) { if (!position) { throw new Error( 'This pointer has no previous position. Provide a target property!', ) } - return position.target ?? config.document.body + return position.target ?? instance.config.document.body } diff --git a/src/setup/config.ts b/src/setup/config.ts deleted file mode 100644 index d4a920ae..00000000 --- a/src/setup/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {Options} from '../options' -import {System} from '../system' - -export interface Config extends Required { - system: System -} -export const Config = Symbol('Config') diff --git a/src/setup/directApi.ts b/src/setup/directApi.ts index f4da5ebc..58b876bc 100644 --- a/src/setup/directApi.ts +++ b/src/setup/directApi.ts @@ -1,7 +1,7 @@ import type {Options} from '../options' import type {PointerInput} from '../pointer' import type {System} from '../system' -import type {UserEventApi} from '.' +import type {UserEventApi} from './setup' import {setupDirect} from './setup' export type DirectOptions = Options & { @@ -42,18 +42,18 @@ export function hover(element: Element, options: DirectOptions = {}) { } export async function keyboard(text: string, options: DirectOptions = {}) { - const {config, api} = setupDirect(options) + const {api, system} = setupDirect(options) - return api.keyboard(text).then(() => config.system) + return api.keyboard(text).then(() => system) } export async function pointer( input: PointerInput, options: DirectOptions = {}, ) { - const {config, api} = setupDirect(options) + const {api, system} = setupDirect(options) - return api.pointer(input).then(() => config.system) + return api.pointer(input).then(() => system) } export function paste( @@ -84,8 +84,8 @@ export function type( } export function unhover(element: Element, options: DirectOptions = {}) { - const {config, api} = setupDirect(options) - config.system.pointer.setMousePosition({target: element}) + const {api, system} = setupDirect(options) + system.pointer.setMousePosition({target: element}) return api.unhover(element) } diff --git a/src/setup/index.ts b/src/setup/index.ts index 7f7193d0..b0308f38 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,25 +1,7 @@ -import type {bindDispatchUIEvent} from '../event' -import type * as userEventApi from './api' -import {setupMain, setupSub} from './setup' -import {Config} from './config' +import {setupMain} from './setup' import * as directApi from './directApi' -export {Config} - -export type UserEventApi = typeof userEventApi - -export type Instance = UserEventApi & { - [Config]: Config - dispatchUIEvent: ReturnType -} - -export type UserEvent = { - readonly setup: (...args: Parameters) => UserEvent -} & { - readonly [k in keyof UserEventApi]: ( - ...args: Parameters - ) => ReturnType -} +export type {Instance} from './setup' export const userEvent = { ...directApi, diff --git a/src/setup/setup.ts b/src/setup/setup.ts index 3158f213..afef6165 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -1,6 +1,8 @@ -import {prepareDocument} from '../document' -import {bindDispatchUIEvent} from '../event' -import {defaultOptionsDirect, defaultOptionsSetup, Options} from '../options' +import {prepareDocument} from '../document/prepareDocument' +import {dispatchEvent, dispatchUIEvent} from '../event' +import {defaultKeyMap as defaultKeyboardMap} from '../keyboard/keyMap' +import {defaultKeyMap as defaultPointerMap} from '../pointer/keyMap' +import {Options, PointerEventsCheckLevel} from '../options' import { ApiLevel, attachClipboardStubToView, @@ -9,14 +11,58 @@ import { wait, } from '../utils' import {System} from '../system' -import type {Instance, UserEvent, UserEventApi} from './index' -import {Config} from './config' import * as userEventApi from './api' import {wrapAsync} from './wrapAsync' import {DirectOptions} from './directApi' +/** + * Default options applied when API is called per `userEvent.anyApi()` + */ +const defaultOptionsDirect: Required = { + applyAccept: true, + autoModify: true, + delay: 0, + document: globalThis.document, + keyboardMap: defaultKeyboardMap, + pointerMap: defaultPointerMap, + pointerEventsCheck: PointerEventsCheckLevel.EachApiCall, + skipAutoClose: false, + skipClick: false, + skipHover: false, + writeToClipboard: false, + advanceTimers: () => Promise.resolve(), +} + +/** + * Default options applied when API is called per `userEvent().anyApi()` + */ +const defaultOptionsSetup: Required = { + ...defaultOptionsDirect, + writeToClipboard: true, +} + +export type UserEventApi = typeof userEventApi + +export type UserEvent = { + readonly setup: (...args: Parameters) => UserEvent +} & { + readonly [k in keyof UserEventApi]: ( + ...args: Parameters + ) => ReturnType +} + +export type Instance = UserEventApi & { + config: Config + dispatchEvent: OmitThisParameter + dispatchUIEvent: OmitThisParameter + system: System + levelRefs: Record +} + +export type Config = Required + export function createConfig( - options: Partial = {}, + options: Options = {}, defaults: Required = defaultOptionsSetup, node?: Node, ): Config { @@ -26,7 +72,6 @@ export function createConfig( ...defaults, ...options, document, - system: options.system ?? new System(), } } @@ -42,7 +87,7 @@ export function setupMain(options: Options = {}) { config.document.defaultView ?? /* istanbul ignore next */ globalThis.window attachClipboardStubToView(view) - return doSetup(config) + return createInstance(config).api } /** @@ -53,23 +98,16 @@ export function setupDirect( keyboardState, pointerState, ...options - }: DirectOptions & // backward-compatibility - {keyboardState?: System; pointerState?: System} = {}, + }: DirectOptions & {keyboardState?: System; pointerState?: System} = {}, // backward-compatibility node?: Node, ) { - const config = createConfig( - { - ...options, - system: pointerState ?? keyboardState, - }, - defaultOptionsDirect, - node, - ) + const config = createConfig(options, defaultOptionsDirect, node) prepareDocument(config.document) + const system = pointerState ?? keyboardState ?? new System() return { - config, - api: doSetup(config), + api: createInstance(config, system).api, + system, } } @@ -77,10 +115,7 @@ export function setupDirect( * Create a set of callbacks with different default settings but the same state. */ export function setupSub(this: Instance, options: Options) { - return doSetup({ - ...this[Config], - ...options, - }) + return createInstance({...this.config, ...options}, this.system).api } function wrapAndBindImpl< @@ -88,11 +123,11 @@ function wrapAndBindImpl< Impl extends (this: Instance, ...args: Args) => Promise, >(instance: Instance, impl: Impl) { function method(...args: Args) { - setLevelRef(instance[Config], ApiLevel.Call) + setLevelRef(instance, ApiLevel.Call) return wrapAsync(() => impl.apply(instance, args).then(async ret => { - await wait(instance[Config]) + await wait(instance.config) return ret }), ) @@ -102,20 +137,34 @@ function wrapAndBindImpl< return method } -function doSetup(config: Config): UserEvent { - const instance: Instance = { - [Config]: config, - dispatchUIEvent: bindDispatchUIEvent(config), +export function createInstance( + config: Config, + system: System = new System(), +): { + instance: Instance + api: UserEvent +} { + const instance = {} as Instance + Object.assign(instance, { + config, + dispatchEvent: dispatchEvent.bind(instance), + dispatchUIEvent: dispatchUIEvent.bind(instance), + system, + levelRefs: {}, ...userEventApi, - } + }) + return { - ...(Object.fromEntries( - Object.entries(userEventApi).map(([name, api]) => [ - name, - wrapAndBindImpl(instance, api), - ]), - ) as UserEventApi), - setup: setupSub.bind(instance), + instance, + api: { + ...(Object.fromEntries( + Object.entries(userEventApi).map(([name, api]) => [ + name, + wrapAndBindImpl(instance, api), + ]), + ) as UserEventApi), + setup: setupSub.bind(instance), + }, } } diff --git a/src/setup/wrapAsync.ts b/src/setup/wrapAsync.ts index 000b601e..83cda376 100644 --- a/src/setup/wrapAsync.ts +++ b/src/setup/wrapAsync.ts @@ -1,4 +1,4 @@ -import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {getConfig} from '@testing-library/dom' /** * Wrap an internal Promise @@ -6,5 +6,5 @@ import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' export function wrapAsync Promise) | (() => R)>( implementation: P, ): Promise { - return getDOMTestingLibraryConfig().asyncWrapper(implementation) + return getConfig().asyncWrapper(implementation) } diff --git a/src/system/keyboard.ts b/src/system/keyboard.ts index a81b3471..d379685d 100644 --- a/src/system/keyboard.ts +++ b/src/system/keyboard.ts @@ -1,5 +1,4 @@ -import {dispatchUIEvent} from '../event' -import {Config} from '../setup' +import type {Instance} from '../setup' import {getActiveElementOrBody} from '../utils' import type {System} from '.' @@ -91,11 +90,11 @@ export class KeyboardHost { } /** Press a key */ - async keydown(config: Config, keyDef: keyboardKey) { + async keydown(instance: Instance, keyDef: keyboardKey) { const key = String(keyDef.key) const code = String(keyDef.code) - const target = getActiveElementOrBody(config.document) + const target = getActiveElementOrBody(instance.config.document) this.setKeydownTarget(target) this.pressed[code] ??= { @@ -107,7 +106,7 @@ export class KeyboardHost { this.modifiers[key] = true } - const unprevented = dispatchUIEvent(config, target, 'keydown', { + const unprevented = instance.dispatchUIEvent(target, 'keydown', { key, code, }) @@ -120,9 +119,8 @@ export class KeyboardHost { this.pressed[code].unpreventedDefault ||= unprevented if (unprevented && this.hasKeyPress(key)) { - dispatchUIEvent( - config, - getActiveElementOrBody(config.document), + instance.dispatchUIEvent( + getActiveElementOrBody(instance.config.document), 'keypress', { key, @@ -135,7 +133,7 @@ export class KeyboardHost { } /** Release a key */ - async keyup(config: Config, keyDef: keyboardKey) { + async keyup(instance: Instance, keyDef: keyboardKey) { const key = String(keyDef.key) const code = String(keyDef.code) @@ -151,9 +149,8 @@ export class KeyboardHost { this.modifiers[key] = false } - dispatchUIEvent( - config, - getActiveElementOrBody(config.document), + instance.dispatchUIEvent( + getActiveElementOrBody(instance.config.document), 'keyup', { key, diff --git a/src/system/pointer/buttons.ts b/src/system/pointer/buttons.ts index d125e4aa..38303a02 100644 --- a/src/system/pointer/buttons.ts +++ b/src/system/pointer/buttons.ts @@ -1,4 +1,4 @@ -import type {pointerKey} from '.' +import type {pointerKey} from './shared' export class Buttons { private readonly pressed: Record = {} diff --git a/src/system/pointer/index.ts b/src/system/pointer/index.ts index c6bfbf81..f0691f3a 100644 --- a/src/system/pointer/index.ts +++ b/src/system/pointer/index.ts @@ -1,30 +1,12 @@ import {System} from '..' -import {PointerCoords} from '../../event' -import {Config} from '../../setup' -import {Buttons, MouseButton} from './buttons' +import {Instance} from '../../setup' +import {Buttons} from './buttons' import {Device} from './device' import {Mouse} from './mouse' import {Pointer} from './pointer' +import {pointerKey, PointerPosition} from './shared' -export interface pointerKey { - /** Name of the pointer key */ - name: string - /** Type of the pointer device */ - pointerType: 'mouse' | 'pen' | 'touch' - /** Type of button */ - button?: MouseButton -} - -export interface PointerPosition { - target?: Element - coords?: PointerCoords - caret?: CaretPosition -} - -export interface CaretPosition { - node?: Node - offset?: number -} +export type {pointerKey, PointerPosition} from './shared' export class PointerHost { readonly system: System @@ -98,11 +80,15 @@ export class PointerHost { return this.devices.get(keyDef.pointerType).isPressed(keyDef) } - async press(config: Config, keyDef: pointerKey, position: PointerPosition) { + async press( + instance: Instance, + keyDef: pointerKey, + position: PointerPosition, + ) { const pointerName = this.getPointerName(keyDef) const pointer = keyDef.pointerType === 'touch' - ? this.pointers.new(pointerName, keyDef).init(config, position) + ? this.pointers.new(pointerName, keyDef).init(instance, position) : this.pointers.get(pointerName) // TODO: deprecate the following implicit setting of position @@ -114,25 +100,29 @@ export class PointerHost { this.devices.get(keyDef.pointerType).addPressed(keyDef) this.buttons.down(keyDef) - pointer.down(config, keyDef) + pointer.down(instance, keyDef) if (pointer.pointerType !== 'touch' && !pointer.isPrevented) { - this.mouse.down(config, keyDef, pointer) + this.mouse.down(instance, keyDef, pointer) } } - async move(config: Config, pointerName: string, position: PointerPosition) { + async move( + instance: Instance, + pointerName: string, + position: PointerPosition, + ) { const pointer = this.pointers.get(pointerName) // In (some?) browsers this order of events can be observed. // This interweaving of events is probably unnecessary. // While the order of mouse (or pointer) events is defined per spec, // the order in which they interweave/follow on a user interaction depends on the implementation. - const pointermove = pointer.move(config, position) + const pointermove = pointer.move(instance, position) const mousemove = pointer.pointerType === 'touch' || (pointer.isPrevented && pointer.isDown) ? undefined - : this.mouse.move(config, position) + : this.mouse.move(instance, position) pointermove?.leave() mousemove?.leave() @@ -142,7 +132,11 @@ export class PointerHost { mousemove?.move() } - async release(config: Config, keyDef: pointerKey, position: PointerPosition) { + async release( + instance: Instance, + keyDef: pointerKey, + position: PointerPosition, + ) { const device = this.devices.get(keyDef.pointerType) device.removePressed(keyDef) @@ -157,29 +151,29 @@ export class PointerHost { } if (device.countPressed === 0) { - pointer.up(config, keyDef) + pointer.up(instance, keyDef) } if (pointer.pointerType === 'touch') { - pointer.release(config) + pointer.release(instance) } if (!pointer.isPrevented) { if (pointer.pointerType === 'touch' && !pointer.isMultitouch) { - const mousemove = this.mouse.move(config, pointer.position) + const mousemove = this.mouse.move(instance, pointer.position) mousemove?.leave() mousemove?.enter() mousemove?.move() - this.mouse.down(config, keyDef, pointer) + this.mouse.down(instance, keyDef, pointer) } if (!pointer.isMultitouch) { - const mousemove = this.mouse.move(config, pointer.position) + const mousemove = this.mouse.move(instance, pointer.position) mousemove?.leave() mousemove?.enter() mousemove?.move() - this.mouse.up(config, keyDef, pointer) + this.mouse.up(instance, keyDef, pointer) } } } @@ -198,8 +192,8 @@ export class PointerHost { this.mouse.resetClickCount() } - getMouseTarget(config: Config) { - return this.mouse.position.target ?? config.document.body + getMouseTarget(instance: Instance) { + return this.mouse.position.target ?? instance.config.document.body } setMousePosition(position: PointerPosition) { @@ -207,16 +201,3 @@ export class PointerHost { this.pointers.get('mouse').position = position } } - -export function isDifferentPointerPosition( - positionA: PointerPosition, - positionB: PointerPosition, -) { - return ( - positionA.target !== positionB.target || - positionA.coords?.x !== positionB.coords?.y || - positionA.coords?.y !== positionB.coords?.y || - positionA.caret?.node !== positionB.caret?.node || - positionA.caret?.offset !== positionB.caret?.offset - ) -} diff --git a/src/system/pointer/mouse.ts b/src/system/pointer/mouse.ts index 14a90581..7a1ea0a9 100644 --- a/src/system/pointer/mouse.ts +++ b/src/system/pointer/mouse.ts @@ -1,16 +1,15 @@ -import {dispatchUIEvent, EventType} from '../../event' -import {Config} from '../../setup' import { - focus, - getTreeDiff, - isDisabled, + EventType, + focusElement, modifySelectionPerMouseMove, SelectionRange, setSelectionPerMouseDown, -} from '../../utils' -import {isDifferentPointerPosition, pointerKey, PointerPosition} from '.' +} from '../../event' +import type {Instance} from '../../setup' +import {getTreeDiff, isDisabled} from '../../utils' import {Buttons, getMouseEventButton, MouseButton} from './buttons' import type {Pointer} from './pointer' +import {isDifferentPointerPosition, pointerKey, PointerPosition} from './shared' /** * This object is the single "virtual" mouse that might be controlled by multiple different pointer devices. @@ -67,9 +66,9 @@ export class Mouse { } })() - move(config: Config, position: PointerPosition) { + move(instance: Instance, position: PointerPosition) { const prevPosition = this.position - const prevTarget = this.getTarget(config) + const prevTarget = this.getTarget(instance) this.position = position @@ -77,7 +76,7 @@ export class Mouse { return } - const nextTarget = this.getTarget(config) + const nextTarget = this.getTarget(instance) const init = this.getEventInit('mousemove') @@ -86,42 +85,41 @@ export class Mouse { return { leave: () => { if (prevTarget !== nextTarget) { - dispatchUIEvent(config, prevTarget, 'mouseout', init) - leave.forEach(el => dispatchUIEvent(config, el, 'mouseleave', init)) + instance.dispatchUIEvent(prevTarget, 'mouseout', init) + leave.forEach(el => instance.dispatchUIEvent(el, 'mouseleave', init)) } }, enter: () => { if (prevTarget !== nextTarget) { - dispatchUIEvent(config, nextTarget, 'mouseover', init) - enter.forEach(el => dispatchUIEvent(config, el, 'mouseenter', init)) + instance.dispatchUIEvent(nextTarget, 'mouseover', init) + enter.forEach(el => instance.dispatchUIEvent(el, 'mouseenter', init)) } }, move: () => { - dispatchUIEvent(config, nextTarget, 'mousemove', init) + instance.dispatchUIEvent(nextTarget, 'mousemove', init) - this.modifySelecting(config) + this.modifySelecting(instance) }, } } - down(config: Config, keyDef: pointerKey, pointer: Pointer) { + down(instance: Instance, keyDef: pointerKey, pointer: Pointer) { const button = this.buttons.down(keyDef) if (button === undefined) { return } - const target = this.getTarget(config) + const target = this.getTarget(instance) this.buttonDownTarget[button] = target const disabled = isDisabled(target) const init = this.getEventInit('mousedown', keyDef.button) - if (disabled || dispatchUIEvent(config, target, 'mousedown', init)) { - this.startSelecting(config, init.detail as number) - focus(target) + if (disabled || instance.dispatchUIEvent(target, 'mousedown', init)) { + this.startSelecting(instance, init.detail as number) + focusElement(target) } if (!disabled && getMouseEventButton(keyDef.button) === 2) { - dispatchUIEvent( - config, + instance.dispatchUIEvent( target, 'contextmenu', this.getEventInit('contextmenu', keyDef.button, pointer), @@ -129,16 +127,15 @@ export class Mouse { } } - up(config: Config, keyDef: pointerKey, pointer: Pointer) { + up(instance: Instance, keyDef: pointerKey, pointer: Pointer) { const button = this.buttons.up(keyDef) if (button === undefined) { return } - const target = this.getTarget(config) + const target = this.getTarget(instance) if (!isDisabled(target)) { - dispatchUIEvent( - config, + instance.dispatchUIEvent( target, 'mouseup', this.getEventInit('mouseup', keyDef.button), @@ -152,14 +149,13 @@ export class Mouse { if (clickTarget) { const init = this.getEventInit('click', keyDef.button, pointer) if (init.detail) { - dispatchUIEvent( - config, + instance.dispatchUIEvent( clickTarget, init.button === 0 ? 'click' : 'auxclick', init, ) if (init.button === 0 && init.detail === 2) { - dispatchUIEvent(config, clickTarget, 'dblclick', { + instance.dispatchUIEvent(clickTarget, 'dblclick', { ...this.getEventInit('dblclick', keyDef.button), detail: init.detail, }) @@ -202,29 +198,29 @@ export class Mouse { return init } - private getTarget(config: Config) { - return this.position.target ?? config.document.body + private getTarget(instance: Instance) { + return this.position.target ?? instance.config.document.body } - private startSelecting(config: Config, clickCount: number) { + private startSelecting(instance: Instance, clickCount: number) { // TODO: support extending range (shift) this.selecting = setSelectionPerMouseDown({ - document: config.document, - target: this.getTarget(config), + document: instance.config.document, + target: this.getTarget(instance), node: this.position.caret?.node, offset: this.position.caret?.offset, clickCount, }) } - private modifySelecting(config: Config) { + private modifySelecting(instance: Instance) { if (!this.selecting) { return } modifySelectionPerMouseMove(this.selecting, { - document: config.document, - target: this.getTarget(config), + document: instance.config.document, + target: this.getTarget(instance), node: this.position.caret?.node, offset: this.position.caret?.offset, }) diff --git a/src/system/pointer/pointer.ts b/src/system/pointer/pointer.ts index d7e50328..d622ca6b 100644 --- a/src/system/pointer/pointer.ts +++ b/src/system/pointer/pointer.ts @@ -1,7 +1,6 @@ -import {dispatchUIEvent} from '../../event' -import {Config} from '../../setup' +import type {Instance} from '../../setup' import {assertPointerEvents, getTreeDiff, hasPointerEvents} from '../../utils' -import {isDifferentPointerPosition, pointerKey, PointerPosition} from '.' +import {isDifferentPointerPosition, pointerKey, PointerPosition} from './shared' type PointerInit = { pointerId: number @@ -27,24 +26,24 @@ export class Pointer { position: PointerPosition = {} - init(config: Config, position: PointerPosition) { + init(instance: Instance, position: PointerPosition) { this.position = position - const target = this.getTarget(config) + const target = this.getTarget(instance) const [, enter] = getTreeDiff(null, target) const init = this.getEventInit() - assertPointerEvents(config, target) + assertPointerEvents(instance, target) - dispatchUIEvent(config, target, 'pointerover', init) - enter.forEach(el => dispatchUIEvent(config, el, 'pointerenter', init)) + instance.dispatchUIEvent(target, 'pointerover', init) + enter.forEach(el => instance.dispatchUIEvent(el, 'pointerenter', init)) return this } - move(config: Config, position: PointerPosition) { + move(instance: Instance, position: PointerPosition) { const prevPosition = this.position - const prevTarget = this.getTarget(config) + const prevTarget = this.getTarget(instance) this.position = position @@ -52,7 +51,7 @@ export class Pointer { return } - const nextTarget = this.getTarget(config) + const nextTarget = this.getTarget(instance) const init = this.getEventInit() @@ -60,76 +59,77 @@ export class Pointer { return { leave: () => { - if (hasPointerEvents(config, prevTarget)) { + if (hasPointerEvents(instance, prevTarget)) { if (prevTarget !== nextTarget) { - dispatchUIEvent(config, prevTarget, 'pointerout', init) + instance.dispatchUIEvent(prevTarget, 'pointerout', init) leave.forEach(el => - dispatchUIEvent(config, el, 'pointerleave', init), + instance.dispatchUIEvent(el, 'pointerleave', init), ) } } }, enter: () => { - assertPointerEvents(config, nextTarget) + assertPointerEvents(instance, nextTarget) if (prevTarget !== nextTarget) { - dispatchUIEvent(config, nextTarget, 'pointerover', init) - enter.forEach(el => dispatchUIEvent(config, el, 'pointerenter', init)) + instance.dispatchUIEvent(nextTarget, 'pointerover', init) + enter.forEach(el => + instance.dispatchUIEvent(el, 'pointerenter', init), + ) } }, move: () => { - dispatchUIEvent(config, nextTarget, 'pointermove', init) + instance.dispatchUIEvent(nextTarget, 'pointermove', init) }, } } - down(config: Config, _keyDef: pointerKey) { + down(instance: Instance, _keyDef: pointerKey) { if (this.isDown) { return } - const target = this.getTarget(config) + const target = this.getTarget(instance) - assertPointerEvents(config, target) + assertPointerEvents(instance, target) this.isDown = true - this.isPrevented = !dispatchUIEvent( - config, + this.isPrevented = !instance.dispatchUIEvent( target, 'pointerdown', this.getEventInit(), ) } - up(config: Config, _keyDef: pointerKey) { + up(instance: Instance, _keyDef: pointerKey) { if (!this.isDown) { return } - const target = this.getTarget(config) + const target = this.getTarget(instance) - assertPointerEvents(config, target) + assertPointerEvents(instance, target) this.isDown = false - dispatchUIEvent(config, target, 'pointerup', this.getEventInit()) + instance.dispatchUIEvent(target, 'pointerup', this.getEventInit()) } - release(config: Config) { - const target = this.getTarget(config) + release(instance: Instance) { + const target = this.getTarget(instance) const [leave] = getTreeDiff(target, null) const init = this.getEventInit() // Currently there is no PointerEventsCheckLevel that would // make this check not use the *asserted* cached value from `up`. /* istanbul ignore else */ - if (hasPointerEvents(config, target)) { - dispatchUIEvent(config, target, 'pointerout', init) - leave.forEach(el => dispatchUIEvent(config, el, 'pointerleave', init)) + if (hasPointerEvents(instance, target)) { + instance.dispatchUIEvent(target, 'pointerout', init) + leave.forEach(el => instance.dispatchUIEvent(el, 'pointerleave', init)) } this.isCancelled = true } - private getTarget(config: Config) { - return this.position.target ?? config.document.body + private getTarget(instance: Instance) { + return this.position.target ?? instance.config.document.body } private getEventInit(): PointerEventInit { diff --git a/src/system/pointer/shared.ts b/src/system/pointer/shared.ts new file mode 100644 index 00000000..04ef8eda --- /dev/null +++ b/src/system/pointer/shared.ts @@ -0,0 +1,35 @@ +import {PointerCoords} from '../../event' +import {MouseButton} from './buttons' + +export interface pointerKey { + /** Name of the pointer key */ + name: string + /** Type of the pointer device */ + pointerType: 'mouse' | 'pen' | 'touch' + /** Type of button */ + button?: MouseButton +} + +export interface PointerPosition { + target?: Element + coords?: PointerCoords + caret?: CaretPosition +} + +export interface CaretPosition { + node?: Node + offset?: number +} + +export function isDifferentPointerPosition( + positionA: PointerPosition, + positionB: PointerPosition, +) { + return ( + positionA.target !== positionB.target || + positionA.coords?.x !== positionB.coords?.y || + positionA.coords?.y !== positionB.coords?.y || + positionA.caret?.node !== positionB.caret?.node || + positionA.caret?.offset !== positionB.caret?.offset + ) +} diff --git a/src/utility/clear.ts b/src/utility/clear.ts index 7cf4daf2..4e563edb 100644 --- a/src/utility/clear.ts +++ b/src/utility/clear.ts @@ -1,19 +1,13 @@ -import {Config, Instance} from '../setup' -import { - focus, - input, - isAllSelected, - isDisabled, - isEditable, - selectAll, -} from '../utils' +import {focusElement, input, isAllSelected, selectAll} from '../event' +import type {Instance} from '../setup' +import {isDisabled, isEditable} from '../utils' export async function clear(this: Instance, element: Element) { if (!isEditable(element) || isDisabled(element)) { throw new Error('clear()` is only supported on editable elements.') } - focus(element) + focusElement(element) if (element.ownerDocument.activeElement !== element) { throw new Error('The element to be cleared could not be focused.') @@ -25,5 +19,5 @@ export async function clear(this: Instance, element: Element) { throw new Error('The element content to be cleared could not be selected.') } - input(this[Config], element, '', 'deleteContentBackward') + input(this, element, '', 'deleteContentBackward') } diff --git a/src/utility/selectOptions.ts b/src/utility/selectOptions.ts index 1a16951e..f76fb42e 100644 --- a/src/utility/selectOptions.ts +++ b/src/utility/selectOptions.ts @@ -1,12 +1,7 @@ import {getConfig} from '@testing-library/dom' -import { - focus, - hasPointerEvents, - isDisabled, - isElementType, - wait, -} from '../utils' -import {Config, Instance} from '../setup' +import {hasPointerEvents, isDisabled, isElementType, wait} from '../utils' +import type {Instance} from '../setup' +import {focusElement} from '../event' export async function selectOptions( this: Instance, @@ -78,9 +73,9 @@ async function selectOptionsBase( if (select.multiple) { for (const option of selectedOptions) { const withPointerEvents = - this[Config].pointerEventsCheck === 0 + this.config.pointerEventsCheck === 0 ? true - : hasPointerEvents(this[Config], option) + : hasPointerEvents(this, option) // events fired for multiple select are weird. Can't use hover... if (withPointerEvents) { @@ -94,7 +89,7 @@ async function selectOptionsBase( this.dispatchUIEvent(option, 'mousedown') } - focus(select) + focusElement(select) if (withPointerEvents) { this.dispatchUIEvent(option, 'pointerup') @@ -107,18 +102,18 @@ async function selectOptionsBase( this.dispatchUIEvent(option, 'click') } - await wait(this[Config]) + await wait(this.config) } } else if (selectedOptions.length === 1) { const withPointerEvents = - this[Config].pointerEventsCheck === 0 + this.config.pointerEventsCheck === 0 ? true - : hasPointerEvents(this[Config], select) + : hasPointerEvents(this, select) // the click to open the select options if (withPointerEvents) { await this.click(select) } else { - focus(select) + focusElement(select) } selectOption(selectedOptions[0] as HTMLOptionElement) @@ -135,7 +130,7 @@ async function selectOptionsBase( this.dispatchUIEvent(select, 'click') } - await wait(this[Config]) + await wait(this.config) } else { throw getConfig().getElementError( `Cannot select multiple options on a non-multiple select`, diff --git a/src/utility/type.ts b/src/utility/type.ts index 7e80940d..2f2da12b 100644 --- a/src/utility/type.ts +++ b/src/utility/type.ts @@ -1,11 +1,11 @@ import type {Instance} from '../setup' -import {setSelectionRange} from '../utils' import {releaseAllKeys} from '../keyboard' -import {Config} from '../setup/config' +import {setSelectionRange} from '../event/selection' +import type {Options} from '../options' export interface typeOptions { - skipClick?: Config['skipClick'] - skipAutoClose?: Config['skipClick'] + skipClick?: Options['skipClick'] + skipAutoClose?: Options['skipAutoClose'] initialSelectionStart?: number initialSelectionEnd?: number } @@ -15,8 +15,8 @@ export async function type( element: Element, text: string, { - skipClick = this[Config].skipClick, - skipAutoClose = this[Config].skipAutoClose, + skipClick = this.config.skipClick, + skipAutoClose = this.config.skipAutoClose, initialSelectionStart, initialSelectionEnd, }: typeOptions = {}, @@ -40,6 +40,6 @@ export async function type( await this.keyboard(text) if (!skipAutoClose) { - await releaseAllKeys(this[Config]) + await releaseAllKeys(this) } } diff --git a/src/utility/upload.ts b/src/utility/upload.ts index 84efc930..3165aa17 100644 --- a/src/utility/upload.ts +++ b/src/utility/upload.ts @@ -5,7 +5,7 @@ import { isElementType, setFiles, } from '../utils' -import {Config, Instance} from '../setup' +import type {Instance} from '../setup' export interface uploadInit { changeInit?: EventInit @@ -29,7 +29,7 @@ export async function upload( const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]) .filter( - file => !this[Config].applyAccept || isAcceptableFile(file, input.accept), + file => !this.config.applyAccept || isAcceptableFile(file, input.accept), ) .slice(0, input.multiple ? undefined : 1) diff --git a/src/utils/click/isClickableInput.ts b/src/utils/click/isClickableInput.ts index 2309be3e..9a782c49 100644 --- a/src/utils/click/isClickableInput.ts +++ b/src/utils/click/isClickableInput.ts @@ -10,13 +10,14 @@ enum clickableInputTypes { 'checkbox' = 'checkbox', 'radio' = 'radio', } -export type ClickableInputType = keyof typeof clickableInputTypes + +export type ClickableInputOrButton = + | HTMLButtonElement + | (HTMLInputElement & {type: clickableInputTypes}) export function isClickableInput( element: Element, -): element is - | HTMLButtonElement - | (HTMLInputElement & {type: clickableInputTypes}) { +): element is ClickableInputOrButton { return ( isElementType(element, 'button') || (isElementType(element, 'input') && element.type in clickableInputTypes) diff --git a/src/utils/dataTransfer/Clipboard.ts b/src/utils/dataTransfer/Clipboard.ts index b1fa15bf..edb32d3b 100644 --- a/src/utils/dataTransfer/Clipboard.ts +++ b/src/utils/dataTransfer/Clipboard.ts @@ -1,7 +1,8 @@ // Clipboard is not available in jsdom -import {createDataTransfer, getBlobFromDataTransferItem, readBlobText} from '..' import {getWindow} from '../misc/getWindow' +import {readBlobText} from './Blob' +import {createDataTransfer, getBlobFromDataTransferItem} from './DataTransfer' // Clipboard API is only fully available in secure context or for browser extensions. diff --git a/src/utils/edit/isEditable.ts b/src/utils/edit/isEditable.ts index 54193f65..0b222777 100644 --- a/src/utils/edit/isEditable.ts +++ b/src/utils/edit/isEditable.ts @@ -8,16 +8,14 @@ export function isEditable( element: Element, ): element is | GuardedType - | GuardedType - | (HTMLTextAreaElement & {readOnly: false}) { + | (EditableInputOrTextarea & {readOnly: false}) { return ( - isEditableInput(element) || - isElementType(element, 'textarea', {readOnly: false}) || + (isEditableInputOrTextArea(element) && !element.readOnly) || isContentEditable(element) ) } -export enum editableInputTypes { +enum editableInputTypes { 'text' = 'text', 'date' = 'date', 'datetime-local' = 'datetime-local', @@ -32,16 +30,15 @@ export enum editableInputTypes { 'week' = 'week', } -export type EditableInputType = keyof typeof editableInputTypes +export type EditableInputOrTextarea = + | HTMLTextAreaElement + | (HTMLInputElement & {type: editableInputTypes}) -export function isEditableInput( +export function isEditableInputOrTextArea( element: Element, -): element is HTMLInputElement & { - readOnly: false - type: editableInputTypes -} { +): element is EditableInputOrTextarea { return ( - isElementType(element, 'input', {readOnly: false}) && - Boolean(editableInputTypes[element.type as editableInputTypes]) + isElementType(element, 'textarea') || + (isElementType(element, 'input') && element.type in editableInputTypes) ) } diff --git a/src/utils/edit/isValidDateOrTimeValue.ts b/src/utils/edit/isValidDateOrTimeValue.ts deleted file mode 100644 index 06c46eb1..00000000 --- a/src/utils/edit/isValidDateOrTimeValue.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function isValidDateOrTimeValue( - element: HTMLInputElement & {type: 'date' | 'time'}, - value: string, -) { - const clone = element.cloneNode() as HTMLInputElement - clone.value = value - return clone.value === value -} diff --git a/src/utils/edit/maxLength.ts b/src/utils/edit/maxLength.ts index a0ce88a9..e5958a16 100644 --- a/src/utils/edit/maxLength.ts +++ b/src/utils/edit/maxLength.ts @@ -1,5 +1,4 @@ import {isElementType} from '../misc/isElementType' -import {getValue} from './getValue' enum maxLengthSupportedTypes { 'email' = 'email', @@ -10,43 +9,23 @@ enum maxLengthSupportedTypes { 'url' = 'url', } -export function getSpaceUntilMaxLength(element: Element) { - const value = getValue(element) - - /* istanbul ignore if */ - if (value === null) { - return undefined - } - - const maxLength = getSanitizedMaxLength(element) - - return maxLength ? maxLength - value.length : undefined -} +type ElementWithMaxLengthSupport = + | HTMLTextAreaElement + | (HTMLInputElement & {type: maxLengthSupportedTypes}) // can't use .maxLength property because of a jsdom bug: // https://github.com/jsdom/jsdom/issues/2927 -function getSanitizedMaxLength(element: Element) { - if (!supportsMaxLength(element)) { - return undefined - } - +export function getMaxLength(element: ElementWithMaxLengthSupport) { const attr = element.getAttribute('maxlength') ?? '' return /^\d+$/.test(attr) && Number(attr) >= 0 ? Number(attr) : undefined } -function supportsMaxLength( +export function supportsMaxLength( element: Element, -): element is - | HTMLTextAreaElement - | (HTMLInputElement & {type: maxLengthSupportedTypes}) { +): element is ElementWithMaxLengthSupport { return ( isElementType(element, 'textarea') || - (isElementType(element, 'input') && - Boolean( - maxLengthSupportedTypes[ - element.type as keyof typeof maxLengthSupportedTypes - ], - )) + (isElementType(element, 'input') && element.type in maxLengthSupportedTypes) ) } diff --git a/src/utils/edit/buildTimeValue.ts b/src/utils/edit/timeValue.ts similarity index 82% rename from src/utils/edit/buildTimeValue.ts rename to src/utils/edit/timeValue.ts index 63c72a3d..bf01c9d5 100644 --- a/src/utils/edit/buildTimeValue.ts +++ b/src/utils/edit/timeValue.ts @@ -32,3 +32,12 @@ function build(onlyDigitsValue: string, index: number): string { .toString() .padStart(2, '0')}` } + +export function isValidDateOrTimeValue( + element: HTMLInputElement & {type: 'date' | 'time'}, + value: string, +) { + const clone = element.cloneNode() as HTMLInputElement + clone.value = value + return clone.value === value +} diff --git a/src/utils/focus/blur.ts b/src/utils/focus/blur.ts deleted file mode 100644 index 1de93f7f..00000000 --- a/src/utils/focus/blur.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {eventWrapper} from '../misc/eventWrapper' -import {getActiveElement} from './getActiveElement' -import {isFocusable} from './isFocusable' - -function blur(element: Element) { - if (!isFocusable(element)) return - - const wasActive = getActiveElement(element.ownerDocument) === element - if (!wasActive) return - - eventWrapper(() => element.blur()) -} - -export {blur} diff --git a/src/utils/focus/cursor.ts b/src/utils/focus/cursor.ts index 30f3f2ca..705de6d3 100644 --- a/src/utils/focus/cursor.ts +++ b/src/utils/focus/cursor.ts @@ -1,4 +1,5 @@ -import {isContentEditable, isElementType} from '..' +import {isContentEditable} from '../edit/isContentEditable' +import {isElementType} from '../misc/isElementType' declare global { interface Text { diff --git a/src/utils/focus/focus.ts b/src/utils/focus/focus.ts deleted file mode 100644 index d55961c6..00000000 --- a/src/utils/focus/focus.ts +++ /dev/null @@ -1,25 +0,0 @@ -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) { - const target = findClosest(element, isFocusable) - - 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()) - } - - updateSelectionOnFocus(target ?? element.ownerDocument.body) -} - -export {focus} diff --git a/src/utils/focus/selection.ts b/src/utils/focus/selection.ts index 7453fbf2..3f1486bc 100644 --- a/src/utils/focus/selection.ts +++ b/src/utils/focus/selection.ts @@ -1,415 +1,24 @@ -import {isElementType} from '../misc/isElementType' import { - getUISelection, - getUIValue, - setUISelection, - UISelectionRange, -} from '../../document' -import {isClickableInput} from '../click/isClickableInput' -import {EditableInputType, editableInputTypes} from '../edit/isEditable' -import {isContentEditable, getContentEditable} from '../edit/isContentEditable' -import {getNextCursorPosition} from './cursor' -import {resolveCaretPosition} from './resolveCaretPosition' - -/** - * Backward-compatible selection. - * - * Handles input elements and contenteditable if it only contains a single text node. - */ -export function setSelectionRange( - element: Element, - anchorOffset: number, - focusOffset: number, -) { - if (hasOwnSelection(element)) { - return setSelection({ - focusNode: element, - anchorOffset, - focusOffset, - }) - } - - /* istanbul ignore else */ - if (isContentEditable(element) && element.firstChild?.nodeType === 3) { - return setSelection({ - focusNode: element.firstChild, - anchorOffset, - focusOffset, - }) - } - - /* istanbul ignore next */ - throw new Error( - 'Not implemented. The result of this interaction is unreliable.', - ) -} + ClickableInputOrButton, + isClickableInput, +} from '../click/isClickableInput' +import { + EditableInputOrTextarea, + isEditableInputOrTextArea, +} from '../edit/isEditable' /** * Determine if the element has its own selection implementation * and does not interact with the Document Selection API. */ -export function hasOwnSelection( - node: Node, -): node is - | HTMLTextAreaElement - | (HTMLInputElement & {type: editableInputTypes}) { - return ( - isElement(node) && - (isElementType(node, 'textarea') || - (isElementType(node, 'input') && node.type in editableInputTypes)) - ) +export function hasOwnSelection(node: Node): node is EditableInputOrTextarea { + return isElement(node) && isEditableInputOrTextArea(node) } -export function hasNoSelection(node: Node) { +export function hasNoSelection(node: Node): node is ClickableInputOrButton { return isElement(node) && isClickableInput(node) } function isElement(node: Node): node is Element { return node.nodeType === 1 } - -/** - * Determine which selection logic and selection ranges to consider. - */ -function getTargetTypeAndSelection(node: Node) { - const element = getElement(node) - - if (element && hasOwnSelection(element)) { - return { - type: 'input', - selection: getUISelection(element), - } as const - } - - const selection = element?.ownerDocument.getSelection() - - // It is possible to extend a single-range selection into a contenteditable. - // This results in the range acting like a range outside of contenteditable. - const isCE = - getContentEditable(node) && - selection?.anchorNode && - getContentEditable(selection.anchorNode) - - return { - type: isCE ? 'contenteditable' : 'default', - selection, - } as const -} - -function getElement(node: Node) { - return node.nodeType === 1 ? (node as Element) : node.parentElement -} - -/** - * Reset the Document Selection when moving focus into an element - * with own selection implementation. - */ -export function updateSelectionOnFocus(element: Element) { - const selection = element.ownerDocument.getSelection() - - /* istanbul ignore if */ - if (!selection?.focusNode) { - return - } - - // If the focus moves inside an element with own selection implementation, - // the document selection will be this element. - // But if the focused element is inside a contenteditable, - // 1) a collapsed selection will be retained. - // 2) other selections will be replaced by a cursor - // 2.a) at the start of the first child if it is a text node - // 2.b) at the start of the contenteditable. - if (hasOwnSelection(element)) { - const contenteditable = getContentEditable(selection.focusNode) - if (contenteditable) { - if (!selection.isCollapsed) { - const focusNode = - contenteditable.firstChild?.nodeType === 3 - ? contenteditable.firstChild - : contenteditable - selection.setBaseAndExtent(focusNode, 0, focusNode, 0) - } - } else { - selection.setBaseAndExtent(element, 0, element, 0) - } - } -} - -/** - * Get the range that would be overwritten by input. - */ -export function getInputRange( - focusNode: Node, -): UISelectionRange | Range | undefined { - const typeAndSelection = getTargetTypeAndSelection(focusNode) - - if (typeAndSelection.type === 'input') { - return typeAndSelection.selection - } else if (typeAndSelection.type === 'contenteditable') { - // Multi-range on contenteditable edits the first selection instead of the last - return typeAndSelection.selection?.getRangeAt(0) - } -} - -/** - * Extend/shrink the selection like with Shift+Arrows or Shift+Mouse - */ -export function modifySelection({ - focusNode, - focusOffset, -}: { - focusNode: Node - /** DOM Offset */ - focusOffset: number -}) { - const typeAndSelection = getTargetTypeAndSelection(focusNode) - - if (typeAndSelection.type === 'input') { - return setUISelection( - focusNode as HTMLInputElement, - { - anchorOffset: typeAndSelection.selection.anchorOffset, - focusOffset, - }, - 'modify', - ) - } - - focusNode.ownerDocument?.getSelection()?.extend(focusNode, focusOffset) -} - -/** - * Set the selection - */ -export function setSelection({ - focusNode, - focusOffset, - anchorNode = focusNode, - anchorOffset = focusOffset, -}: { - anchorNode?: Node - /** DOM offset */ - anchorOffset?: number - focusNode: Node - focusOffset: number -}) { - const typeAndSelection = getTargetTypeAndSelection(focusNode) - - if (typeAndSelection.type === 'input') { - return setUISelection(focusNode as HTMLInputElement, { - anchorOffset, - focusOffset, - }) - } - - anchorNode.ownerDocument - ?.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() - - if (!selection?.focusNode) { - return - } - - if (selection.isCollapsed) { - const nextPosition = getNextCursorPosition( - selection.focusNode, - selection.focusOffset, - direction, - ) - if (nextPosition) { - setSelection({ - focusNode: nextPosition.node, - focusOffset: nextPosition.offset, - }) - } - } else { - selection[direction < 0 ? 'collapseToStart' : 'collapseToEnd']() - } - } -} - -export type SelectionRange = { - node: (HTMLInputElement & {type: EditableInputType}) | HTMLTextAreaElement - start: number - end: number -} - -export function setSelectionPerMouseDown({ - document, - target, - clickCount, - node, - offset, -}: { - document: Document - target: Element - clickCount: number - node?: Node - offset?: number -}) { - if (hasNoSelection(target)) { - return - } - const targetHasOwnSelection = hasOwnSelection(target) - - // On non-input elements the text selection per multiple click - // can extend beyond the target boundaries. - // The exact mechanism what is considered in the same line is unclear. - // Looks it might be every inline element. - // TODO: Check what might be considered part of the same line of text. - const text = String( - targetHasOwnSelection ? getUIValue(target) : target.textContent, - ) - - const [start, end] = node - ? // As offset is describing a DOMOffset it is non-trivial to determine - // which elements might be considered in the same line of text. - // TODO: support expanding initial range on multiple clicks if node is given - [offset, offset] - : getTextRange(text, offset, clickCount) - - // TODO: implement modifying selection per shift/ctrl+mouse - if (targetHasOwnSelection) { - setUISelection(target, { - anchorOffset: start ?? text.length, - focusOffset: end ?? text.length, - }) - return { - node: target, - start: start ?? 0, - end: end ?? text.length, - } - } else { - const {node: startNode, offset: startOffset} = resolveCaretPosition({ - target, - node, - offset: start, - }) - const {node: endNode, offset: endOffset} = resolveCaretPosition({ - target, - node, - offset: end, - }) - - const range = target.ownerDocument.createRange() - try { - range.setStart(startNode, startOffset) - range.setEnd(endNode, endOffset) - } catch (e: unknown) { - throw new Error('The given offset is out of bounds.') - } - - const selection = document.getSelection() - selection?.removeAllRanges() - selection?.addRange(range.cloneRange()) - - return range - } -} - -function getTextRange( - text: string, - pos: number | undefined, - clickCount: number, -) { - if (clickCount % 3 === 1 || text.length === 0) { - return [pos, pos] - } - - const textPos = pos ?? text.length - if (clickCount % 3 === 2) { - return [ - textPos - - (text.substr(0, pos).match(/(\w+|\s+|\W)?$/) as RegExpMatchArray)[0] - .length, - pos === undefined - ? pos - : pos + - (text.substr(pos).match(/^(\w+|\s+|\W)?/) as RegExpMatchArray)[0] - .length, - ] - } - - // triple click - return [ - textPos - - (text.substr(0, pos).match(/[^\r\n]*$/) as RegExpMatchArray)[0].length, - pos === undefined - ? pos - : pos + - (text.substr(pos).match(/^[^\r\n]*/) as RegExpMatchArray)[0].length, - ] -} - -export function modifySelectionPerMouseMove( - selectionRange: Range | SelectionRange, - { - document, - target, - node, - offset, - }: { - document: Document - target: Element - node?: Node - offset?: number - }, -) { - const selectionFocus = resolveCaretPosition({target, node, offset}) - - if ('node' in selectionRange) { - // When the mouse is dragged outside of an input/textarea, - // the selection is extended to the beginning or end of the input - // depending on pointer position. - // TODO: extend selection according to pointer position - /* istanbul ignore else */ - if (selectionFocus.node === selectionRange.node) { - const anchorOffset = - selectionFocus.offset < selectionRange.start - ? selectionRange.end - : selectionRange.start - const focusOffset = - selectionFocus.offset > selectionRange.end || - selectionFocus.offset < selectionRange.start - ? selectionFocus.offset - : selectionRange.end - - setUISelection(selectionRange.node, {anchorOffset, focusOffset}) - } - } else { - const range = selectionRange.cloneRange() - - const cmp = range.comparePoint(selectionFocus.node, selectionFocus.offset) - if (cmp < 0) { - range.setStart(selectionFocus.node, selectionFocus.offset) - } else if (cmp > 0) { - range.setEnd(selectionFocus.node, selectionFocus.offset) - } - - const selection = document.getSelection() - selection?.removeAllRanges() - selection?.addRange(range.cloneRange()) - } -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 593c3129..8ab15cc8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,29 +5,22 @@ export * from './dataTransfer/DataTransfer' export * from './dataTransfer/FileList' export * from './dataTransfer/Clipboard' -export * from './edit/getValue' -export * from './edit/input' +export * from './edit/timeValue' export * from './edit/isContentEditable' export * from './edit/isEditable' +export * from './edit/maxLength' export * from './edit/setFiles' -export * from './edit/walkRadio' -export * from './focus/blur' -export * from './focus/copySelection' export * from './focus/cursor' -export * from './focus/focus' export * from './focus/getActiveElement' export * from './focus/getTabDestination' export * from './focus/isFocusable' -export * from './focus/selectAll' -export * from './focus/resolveCaretPosition' export * from './focus/selection' export * from './focus/selector' export * from './keyDef/readNextDescriptor' export * from './misc/cloneEvent' -export * from './misc/eventWrapper' export * from './misc/findClosest' export * from './misc/getDocumentFromNode' export * from './misc/getTreeDiff' diff --git a/src/utils/misc/eventWrapper.ts b/src/utils/misc/eventWrapper.ts deleted file mode 100644 index 12fc3f27..00000000 --- a/src/utils/misc/eventWrapper.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {getConfig} from '@testing-library/dom' - -export function eventWrapper(cb: () => T): T | undefined { - let result - getConfig().eventWrapper(() => { - result = cb() - }) - return result -} diff --git a/src/utils/misc/level.ts b/src/utils/misc/level.ts index 6e18fc0f..d30114da 100644 --- a/src/utils/misc/level.ts +++ b/src/utils/misc/level.ts @@ -1,25 +1,16 @@ -import {Config} from '../../setup' +import type {Instance} from '../../setup' export enum ApiLevel { Trigger = 2, Call = 1, } -const Level = Symbol('Api level refs') -interface LevelRefs { - [k: number]: object | undefined -} -declare module '../../setup' { - interface Config { - [Level]?: LevelRefs - } -} +export type LevelRefs = Record -export function setLevelRef(config: Config, level: ApiLevel) { - config[Level] ??= {} - config[Level][level] = {} +export function setLevelRef(instance: Instance, level: ApiLevel) { + instance.levelRefs[level] = {} } -export function getLevelRef(config: Config, level: ApiLevel) { - return config[Level]?.[level] +export function getLevelRef(instance: Instance, level: ApiLevel) { + return instance.levelRefs[level] } diff --git a/src/utils/misc/wait.ts b/src/utils/misc/wait.ts index fdd59c5a..edd13a6f 100644 --- a/src/utils/misc/wait.ts +++ b/src/utils/misc/wait.ts @@ -1,6 +1,6 @@ -import {Config} from '../../setup' +import type {Instance} from '../../setup' -export function wait(config: Config) { +export function wait(config: Instance['config']) { const delay = config.delay if (typeof delay !== 'number') { return diff --git a/src/utils/pointer/cssPointerEvents.ts b/src/utils/pointer/cssPointerEvents.ts index 3ef25258..7be9498f 100644 --- a/src/utils/pointer/cssPointerEvents.ts +++ b/src/utils/pointer/cssPointerEvents.ts @@ -1,11 +1,14 @@ import {PointerEventsCheckLevel} from '../../options' -import {Config} from '../../setup' -import {ApiLevel, getLevelRef} from '..' +import type {Instance} from '../../setup' import {getWindow} from '../misc/getWindow' import {isElementType} from '../misc/isElementType' +import {ApiLevel, getLevelRef} from '../misc/level' -export function hasPointerEvents(config: Config, element: Element): boolean { - return checkPointerEvents(config, element)?.pointerEvents !== 'none' +export function hasPointerEvents( + instance: Instance, + element: Element, +): boolean { + return checkPointerEvents(instance, element)?.pointerEvents !== 'none' } function closestPointerEventsDeclaration(element: Element): @@ -42,22 +45,23 @@ declare global { } } -function checkPointerEvents(config: Config, element: Element) { +function checkPointerEvents(instance: Instance, element: Element) { const lastCheck = element[PointerEventsCheck] const needsCheck = - config.pointerEventsCheck !== PointerEventsCheckLevel.Never && + instance.config.pointerEventsCheck !== PointerEventsCheckLevel.Never && (!lastCheck || (hasBitFlag( - config.pointerEventsCheck, + instance.config.pointerEventsCheck, PointerEventsCheckLevel.EachApiCall, ) && - lastCheck[ApiLevel.Call] !== getLevelRef(config, ApiLevel.Call)) || + lastCheck[ApiLevel.Call] !== getLevelRef(instance, ApiLevel.Call)) || (hasBitFlag( - config.pointerEventsCheck, + instance.config.pointerEventsCheck, PointerEventsCheckLevel.EachTrigger, ) && - lastCheck[ApiLevel.Trigger] !== getLevelRef(config, ApiLevel.Trigger))) + lastCheck[ApiLevel.Trigger] !== + getLevelRef(instance, ApiLevel.Trigger))) if (!needsCheck) { return lastCheck?.result @@ -66,16 +70,16 @@ function checkPointerEvents(config: Config, element: Element) { const declaration = closestPointerEventsDeclaration(element) element[PointerEventsCheck] = { - [ApiLevel.Call]: getLevelRef(config, ApiLevel.Call), - [ApiLevel.Trigger]: getLevelRef(config, ApiLevel.Trigger), + [ApiLevel.Call]: getLevelRef(instance, ApiLevel.Call), + [ApiLevel.Trigger]: getLevelRef(instance, ApiLevel.Trigger), result: declaration, } return declaration } -export function assertPointerEvents(config: Config, element: Element) { - const declaration = checkPointerEvents(config, element) +export function assertPointerEvents(instance: Instance, element: Element) { + const declaration = checkPointerEvents(instance, element) if (declaration?.pointerEvents === 'none') { throw new Error( diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index 0c2719d8..e9ae6d9c 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -1,7 +1,8 @@ import {addListeners, EventHandlers} from './listeners' import userEvent from '#src' import {Options} from '#src/options' -import {FOCUSABLE_SELECTOR, setSelection} from '#src/utils' +import {FOCUSABLE_SELECTOR} from '#src/utils' +import {setSelection} from '#src/event/selection' export function render( ui: string, diff --git a/tests/document/index.ts b/tests/document/index.ts index 3326a773..00c5f2f5 100644 --- a/tests/document/index.ts +++ b/tests/document/index.ts @@ -1,11 +1,11 @@ import {render} from '#testHelpers' import { - prepareDocument, getUIValue, setUIValue, getUISelection, setUISelection, } from '#src/document' +import {prepareDocument} from '#src/document/prepareDocument' function prepare(element: Element) { prepareDocument(element.ownerDocument) diff --git a/tests/event/behavior/keydown.ts b/tests/event/behavior/keydown.ts index 142a5f2b..42feb6c0 100644 --- a/tests/event/behavior/keydown.ts +++ b/tests/event/behavior/keydown.ts @@ -1,15 +1,18 @@ import cases from 'jest-in-case' import {getUISelection} from '#src/document' -import {dispatchUIEvent} from '#src/event' -import {createConfig} from '#src/setup/setup' import {render} from '#testHelpers' +import {createConfig, createInstance} from '#src/setup/setup' + +function setupInstance() { + return createInstance(createConfig()).instance +} describe('restrict certain keydown behavior to editable context', () => { ;['Backspace', 'Delete', 'End', 'Home', 'PageUp', 'PageDown'].forEach(key => { test(key, () => { const {element, getEvents} = render(`
`) - dispatchUIEvent(createConfig(), element, 'keydown', {key}) + setupInstance().dispatchUIEvent(element, 'keydown', {key}) expect(getEvents().map(e => e.type)).toEqual(['keydown']) }) @@ -23,7 +26,7 @@ cases( selection, }) - dispatchUIEvent(createConfig(), element, 'keydown', { + setupInstance().dispatchUIEvent(element, 'keydown', { key, }) @@ -95,7 +98,7 @@ cases( ) expected.forEach(expectedSelection => { - dispatchUIEvent(createConfig(), div, 'keydown', {key}) + setupInstance().dispatchUIEvent(div, 'keydown', {key}) const {focusNode, focusOffset} = document.getSelection() as Selection expect({focusNode, focusOffset}).toEqual({ @@ -188,7 +191,7 @@ cases( selection: {focusNode: 'div/text()', anchorOffset: 2, focusOffset: 4}, }) - dispatchUIEvent(createConfig(), element, 'keydown', {key}) + setupInstance().dispatchUIEvent(element, 'keydown', {key}) const {focusNode, focusOffset} = document.getSelection() as Selection expect({focusNode, focusOffset}).toEqual({ @@ -213,10 +216,10 @@ test('select input per `Control+A`', async () => { selection: {focusOffset: 5}, }) - const config = createConfig() - config.system.keyboard.modifiers.Control = true + const instance = setupInstance() + instance.system.keyboard.modifiers.Control = true - dispatchUIEvent(config, element, 'keydown', {code: 'KeyA'}) + instance.dispatchUIEvent(element, 'keydown', {code: 'KeyA'}) expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 11) @@ -229,7 +232,7 @@ cases( selection: {focusOffset: 2}, }) - dispatchUIEvent(createConfig(), element, 'keydown', {key}) + setupInstance().dispatchUIEvent(element, 'keydown', {key}) expect(getEvents('input')[0]).toHaveProperty('inputType', inputType) expect(element).toHaveValue(expectedValue) @@ -258,10 +261,10 @@ cases( }, ) - const config = createConfig() - config.system.keyboard.modifiers.Shift = shiftKey + const instance = setupInstance() + instance.system.keyboard.modifiers.Shift = shiftKey - dispatchUIEvent(config, document.activeElement as Element, 'keydown', { + instance.dispatchUIEvent(document.activeElement as Element, 'keydown', { key: 'Tab', }) @@ -321,7 +324,7 @@ cases( ) const active = document.activeElement as Element - dispatchUIEvent(createConfig(), active, 'keydown', {key}) + setupInstance().dispatchUIEvent(active, 'keydown', {key}) if (expectedTarget) { const target = xpathNode(expectedTarget) diff --git a/tests/event/behavior/keypress.ts b/tests/event/behavior/keypress.ts index bdc22170..c4007bae 100644 --- a/tests/event/behavior/keypress.ts +++ b/tests/event/behavior/keypress.ts @@ -1,12 +1,15 @@ import cases from 'jest-in-case' -import {dispatchUIEvent} from '#src/event' -import {createConfig} from '#src/setup/setup' +import {createConfig, createInstance} from '#src/setup/setup' import {render} from '#testHelpers' +function setupInstance() { + return createInstance(createConfig()).instance +} + test('trigger input event for character key', () => { const {element, getEvents} = render(``) - dispatchUIEvent(createConfig(), element, 'keypress', {key: 'x'}) + setupInstance().dispatchUIEvent(element, 'keypress', {key: 'x'}) expect(getEvents('input')).toHaveLength(1) expect(getEvents('input')[0]).toHaveProperty('data', 'x') @@ -17,7 +20,7 @@ test('trigger input event for character key', () => { test('do not trigger input event outside of editable context', () => { const {element, eventWasFired} = render(`
`) - dispatchUIEvent(createConfig(), element, 'keypress', {key: 'x'}) + setupInstance().dispatchUIEvent(element, 'keypress', {key: 'x'}) expect(eventWasFired('beforeinput')).toBe(false) expect(eventWasFired('input')).toBe(false) @@ -30,12 +33,12 @@ cases( selection: {focusOffset: 0}, }) - const config = createConfig() + const instance = setupInstance() if (shiftKey) { - config.system.keyboard.modifiers.Shift = true + instance.system.keyboard.modifiers.Shift = true } - dispatchUIEvent(config, element, 'keypress', { + instance.dispatchUIEvent(element, 'keypress', { key: 'Enter', shiftKey, }) @@ -80,7 +83,7 @@ test('trigger input event for [Enter] on textarea', () => { ``, ) - dispatchUIEvent(createConfig(), element, 'keypress', {key: 'x'}) + setupInstance().dispatchUIEvent(element, 'keypress', {key: 'x'}) expect(getEvents('input')).toHaveLength(1) expect(getEvents('input')[0]).toHaveProperty('data', 'x') @@ -93,7 +96,7 @@ cases( ({html, hasClick = true}) => { const {element, eventWasFired, getEvents} = render(html) - dispatchUIEvent(createConfig(), element, 'keypress', {key: 'Enter'}) + setupInstance().dispatchUIEvent(element, 'keypress', {key: 'Enter'}) expect(eventWasFired('click')).toBe(hasClick) if (hasClick) { @@ -126,7 +129,7 @@ cases( async ({html, click, submit}) => { const {eventWasFired, xpathNode} = render(html, {focus: 'form/*[2]'}) - dispatchUIEvent(createConfig(), xpathNode('form/*[2]'), 'keypress', { + setupInstance().dispatchUIEvent(xpathNode('form/*[2]'), 'keypress', { key: 'Enter', }) diff --git a/tests/event/behavior/keyup.ts b/tests/event/behavior/keyup.ts index 1349c126..15796f6b 100644 --- a/tests/event/behavior/keyup.ts +++ b/tests/event/behavior/keyup.ts @@ -1,14 +1,17 @@ import cases from 'jest-in-case' -import {dispatchUIEvent} from '#src/event' -import {createConfig} from '#src/setup/setup' +import {createConfig, createInstance} from '#src/setup/setup' import {render} from '#testHelpers' +function setupInstance() { + return createInstance(createConfig()).instance +} + cases( 'trigger click for [Space]', ({html, hasClick = true, hasChange = false}) => { const {element, eventWasFired, getEvents} = render(html) - dispatchUIEvent(createConfig(), element, 'keyup', {key: ' '}) + setupInstance().dispatchUIEvent(element, 'keyup', {key: ' '}) expect(eventWasFired('click')).toBe(hasClick) if (hasClick) { diff --git a/tests/event/dispatchEvent.ts b/tests/event/dispatchEvent.ts index 94f6c8c0..fd5bfa72 100644 --- a/tests/event/dispatchEvent.ts +++ b/tests/event/dispatchEvent.ts @@ -1,6 +1,5 @@ -import {dispatchUIEvent} from '#src/event' import {behavior, BehaviorPlugin} from '#src/event/behavior' -import {createConfig} from '#src/setup/setup' +import {createConfig, createInstance} from '#src/setup/setup' import {render} from '#testHelpers' jest.mock('#src/event/behavior', () => ({ @@ -17,10 +16,14 @@ afterEach(() => { jest.clearAllMocks() }) +function setupInstance() { + return createInstance(createConfig()).instance +} + test('keep default behavior', () => { const {element} = render(``) - dispatchUIEvent(createConfig(), element, 'click') + setupInstance().dispatchUIEvent(element, 'click') expect(mockPlugin).toBeCalledTimes(1) expect(element).toBeChecked() @@ -32,7 +35,7 @@ test('replace default behavior', () => { const mockBehavior = jest.fn() mockPlugin.mockImplementationOnce(() => mockBehavior) - dispatchUIEvent(createConfig(), element, 'click') + setupInstance().dispatchUIEvent(element, 'click') expect(mockPlugin).toBeCalledTimes(1) expect(element).not.toBeChecked() @@ -50,7 +53,7 @@ test('prevent replaced default behavior', () => { const mockBehavior = jest.fn() mockPlugin.mockImplementationOnce(() => mockBehavior) - dispatchUIEvent(createConfig(), element, 'click') + setupInstance().dispatchUIEvent(element, 'click') expect(mockPlugin).toBeCalledTimes(1) expect(element).not.toBeChecked() diff --git a/tests/utils/focus/focus.ts b/tests/event/focus.ts similarity index 59% rename from tests/utils/focus/focus.ts rename to tests/event/focus.ts index 4aaa702a..848c5b84 100644 --- a/tests/utils/focus/focus.ts +++ b/tests/event/focus.ts @@ -1,4 +1,4 @@ -import {focus} from '#src/utils' +import {focusElement, blurElement} from '#src/event' import {addListeners, setup} from '#testHelpers' test('move focus', async () => { @@ -7,12 +7,12 @@ test('move focus', async () => { ) const [elA, elB] = elements - focus(elA) + focusElement(elA) expect(elA).toHaveFocus() clearEventCalls() - focus(elB) + focusElement(elB) expect(elB).toHaveFocus() expect(getEventSnapshot()).toMatchInlineSnapshot(` @@ -27,7 +27,7 @@ test('move focus', async () => { test('no events fired on an unfocusable input', async () => { const {element, getEventSnapshot} = setup(`
`) - focus(element) + focusElement(element) expect(getEventSnapshot()).toMatchInlineSnapshot( `No events were fired on: div`, ) @@ -38,7 +38,7 @@ test('focus with tabindex', async () => { const {element, getEventSnapshot} = setup(`
`, { focus: false, }) - focus(element) + focusElement(element) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: div @@ -50,7 +50,7 @@ test('focus with tabindex', async () => { test('no events fired on a disabled focusable input', async () => { const {element, getEventSnapshot} = setup(``) - const config = createConfig() + const {instance} = createInstance(createConfig()) - await config.system.pointer.press( - config, + await instance.system.pointer.press( + instance, {name: 'a', pointerType: 'mouse'}, {target: element}, ) - await config.system.pointer.press( - config, + await instance.system.pointer.press( + instance, {name: 'b', pointerType: 'mouse'}, {target: element}, ) - await config.system.pointer.press( - config, + await instance.system.pointer.press( + instance, {name: 'b', pointerType: 'mouse'}, {target: element}, ) expect(getEvents('pointerdown')).toHaveLength(1) expect(getEvents('mousedown')).toHaveLength(1) - await config.system.pointer.release( - config, + await instance.system.pointer.release( + instance, {name: 'a', pointerType: 'mouse'}, {target: element}, ) - await config.system.pointer.release( - config, + await instance.system.pointer.release( + instance, {name: 'b', pointerType: 'mouse'}, {target: element}, ) - await config.system.pointer.release( - config, + await instance.system.pointer.release( + instance, {name: 'b', pointerType: 'mouse'}, {target: element}, ) diff --git a/tests/utils/focus/blur.ts b/tests/utils/focus/blur.ts deleted file mode 100644 index 9088d556..00000000 --- a/tests/utils/focus/blur.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {blur} from '#src/utils' -import {setup} from '#testHelpers' - -test('blur a button', async () => { - const {element, getEventSnapshot} = setup(`
`) - expect(() => assertPointerEvents(createConfig(), element)) + expect(() => assertPointerEvents(setupInstance(), element)) .toThrowErrorMatchingInlineSnapshot(` Unable to perform pointer interaction as the element has \`pointer-events: none\`: @@ -43,7 +47,7 @@ test('report element that declared pointer-events', async () => { expect(() => assertPointerEvents( - createConfig(), + setupInstance(), element.querySelector('[data-testid="target"]') as Element, ), ).toThrowErrorMatchingInlineSnapshot(` @@ -57,7 +61,7 @@ test('report element that declared pointer-events', async () => { expect(() => assertPointerEvents( - createConfig(), + setupInstance(), element.querySelector('button') as Element, ), ).toThrowErrorMatchingInlineSnapshot(` @@ -71,7 +75,7 @@ test('report element that declared pointer-events', async () => { expect(() => assertPointerEvents( - createConfig(), + setupInstance(), element.querySelector('input') as Element, ), ).toThrowErrorMatchingInlineSnapshot(`