From 2852509f8e4d5ca28ec494cc85dc4020b0e44b45 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 2 Aug 2022 14:31:53 +0200 Subject: [PATCH] feat: separate input devices from pointer/keyboard API (#1003) feat: * **pointer**: dispatch `auxclick` events fix: * **keyboard**: switch modifier state of lock keys on the correct event * **keyboard**: remove platform-specific additional key events for `Control` on `AltGraph` * **pointer**: dispatch `contextmenu` events with `detail: 0` * **pointer**: always set `PointerEvent.isPrimary` * **pointer**: set `button` property on pointer events separately from legacy mouse events * **pointer**: click closest common ancestor if `mousedown` and `mouseup` happen on different elements * **pointer**: omit click event on release if another button is released first * **pointer**: dispatch `mouseover`, `mouseenter` and `mousemove` on disabled elements * **pointer**: prevent `mouse*` events per `pointerdown` event handler * **pointer**: dispatch `*out` and `*over` events when moving into / out of nested elements * **pointer**: dispatch `*enter` and `*leave` events on ancestors --- src/convenience/hover.ts | 2 +- src/event/behavior/keydown.ts | 9 +- src/event/behavior/keypress.ts | 2 +- src/event/createEvent.ts | 3 +- src/event/eventMap.ts | 21 + src/event/eventTypes.ts | 22 -- src/event/index.ts | 5 +- src/index.ts | 4 +- src/keyboard/index.ts | 68 ++-- src/keyboard/keyMap.ts | 2 +- src/keyboard/keyboardAction.ts | 148 ------- src/keyboard/modifiers.ts | 90 ----- src/keyboard/parseKeyDef.ts | 2 +- src/keyboard/types.ts | 76 ---- src/options.ts | 4 +- src/pointer/firePointerEvents.ts | 48 --- src/pointer/index.ts | 120 ++++-- src/pointer/keyMap.ts | 2 +- src/pointer/parseKeyDef.ts | 2 +- src/pointer/pointerAction.ts | 56 --- src/pointer/pointerMove.ts | 146 ------- src/pointer/pointerPress.ts | 361 ------------------ src/pointer/types.ts | 73 ---- src/setup/config.ts | 10 +- src/setup/directApi.ts | 44 ++- src/setup/index.ts | 3 +- src/setup/setup.ts | 31 +- src/system/index.ts | 27 ++ src/system/keyboard.ts | 188 +++++++++ src/system/pointer/buttons.ts | 74 ++++ src/system/pointer/device.ts | 21 + src/system/pointer/index.ts | 222 +++++++++++ src/system/pointer/mouse.ts | 236 ++++++++++++ src/system/pointer/pointer.ts | 143 +++++++ src/utils/click/isClickableInput.ts | 30 +- src/utils/focus/getActiveElement.ts | 4 + .../focus/resolveCaretPosition.ts} | 18 +- src/utils/focus/selection.ts | 179 ++++++++- src/utils/index.ts | 6 +- src/utils/keyboard/getKeyEventProps.ts | 8 - src/utils/keyboard/getUIEventModifiers.ts | 18 - src/utils/misc/getTreeDiff.ts | 25 ++ src/utils/pointer/mouseButtons.ts | 36 -- tests/_helpers/listeners.ts | 7 +- tests/convenience/hover.ts | 9 +- tests/event/behavior/keydown.ts | 4 +- tests/event/behavior/keypress.ts | 2 +- tests/keyboard/modifiers.ts | 29 +- tests/keyboard/parseKeyDef.ts | 2 +- tests/pointer/click.ts | 94 +++-- tests/pointer/drag.ts | 14 +- tests/pointer/index.ts | 25 +- tests/pointer/move.ts | 19 +- tests/pointer/select.ts | 21 +- tests/system/pointer.ts | 43 +++ tests/utility/selectOptions/select.ts | 6 +- tests/utility/upload.ts | 2 + 57 files changed, 1562 insertions(+), 1304 deletions(-) delete mode 100644 src/event/eventTypes.ts delete mode 100644 src/keyboard/keyboardAction.ts delete mode 100644 src/keyboard/modifiers.ts delete mode 100644 src/keyboard/types.ts delete mode 100644 src/pointer/firePointerEvents.ts delete mode 100644 src/pointer/pointerAction.ts delete mode 100644 src/pointer/pointerMove.ts delete mode 100644 src/pointer/pointerPress.ts delete mode 100644 src/pointer/types.ts create mode 100644 src/system/index.ts create mode 100644 src/system/keyboard.ts create mode 100644 src/system/pointer/buttons.ts create mode 100644 src/system/pointer/device.ts create mode 100644 src/system/pointer/index.ts create mode 100644 src/system/pointer/mouse.ts create mode 100644 src/system/pointer/pointer.ts rename src/{pointer/resolveSelectionTarget.ts => utils/focus/resolveCaretPosition.ts} (85%) delete mode 100644 src/utils/keyboard/getKeyEventProps.ts delete mode 100644 src/utils/keyboard/getUIEventModifiers.ts create mode 100644 src/utils/misc/getTreeDiff.ts delete mode 100644 src/utils/pointer/mouseButtons.ts create mode 100644 tests/system/pointer.ts diff --git a/src/convenience/hover.ts b/src/convenience/hover.ts index df647b2a..8503a66e 100644 --- a/src/convenience/hover.ts +++ b/src/convenience/hover.ts @@ -8,7 +8,7 @@ export async function hover(this: Instance, element: Element) { export async function unhover(this: Instance, element: Element) { assertPointerEvents( this[Config], - this[Config].pointerState.position.mouse.target as Element, + this[Config].system.pointer.getMouseTarget(this[Config]), ) return this.pointer({target: element.ownerDocument.body}) } diff --git a/src/event/behavior/keydown.ts b/src/event/behavior/keydown.ts index ac43781a..969446b9 100644 --- a/src/event/behavior/keydown.ts +++ b/src/event/behavior/keydown.ts @@ -102,9 +102,12 @@ const keydownBehavior: { } } }, - Tab: (event, target, {keyboardState}) => { + Tab: (event, target, config) => { return () => { - const dest = getTabDestination(target, keyboardState.modifiers.Shift) + const dest = getTabDestination( + target, + config.system.keyboard.modifiers.Shift, + ) focus(dest) if (hasOwnSelection(dest)) { setUISelection(dest, { @@ -121,7 +124,7 @@ const combinationBehavior: BehaviorPlugin<'keydown'> = ( target, config, ) => { - if (event.code === 'KeyA' && config.keyboardState.modifiers.Control) { + if (event.code === 'KeyA' && config.system.keyboard.modifiers.Control) { return () => selectAll(target) } } diff --git a/src/event/behavior/keypress.ts b/src/event/behavior/keypress.ts index a52b9bc3..a198dc44 100644 --- a/src/event/behavior/keypress.ts +++ b/src/event/behavior/keypress.ts @@ -37,7 +37,7 @@ behavior.keypress = (event, target, config) => { if (isEditable(target)) { const inputType = event.key === 'Enter' - ? isContentEditable(target) && !config.keyboardState.modifiers.Shift + ? isContentEditable(target) && !config.system.keyboard.modifiers.Shift ? 'insertParagraph' : 'insertLineBreak' : 'insertText' diff --git a/src/event/createEvent.ts b/src/event/createEvent.ts index e3f5f41a..cbe89e58 100644 --- a/src/event/createEvent.ts +++ b/src/event/createEvent.ts @@ -1,6 +1,5 @@ import {createEvent as createEventBase} from '@testing-library/dom' -import {eventMap, eventMapKeys} from './eventMap' -import {isMouseEvent} from './eventTypes' +import {eventMap, eventMapKeys, isMouseEvent} from './eventMap' import {EventType, PointerCoords} from './types' export type EventTypeInit = SpecificEventInit< diff --git a/src/event/eventMap.ts b/src/event/eventMap.ts index cd86ccf8..75a80cc1 100644 --- a/src/event/eventMap.ts +++ b/src/event/eventMap.ts @@ -1,8 +1,15 @@ import {eventMap as baseEventMap} from '@testing-library/dom/dist/event-map.js' +import {EventType} from './types' export const eventMap = { ...baseEventMap, + auxclick: { + // like other events this should be PointerEvent, but this is missing in Jsdom + // see https://github.com/jsdom/jsdom/issues/2527 + EventType: 'MouseEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, beforeInput: { EventType: 'InputEvent', defaultInit: {bubbles: true, cancelable: true, composed: true}, @@ -12,3 +19,17 @@ export const eventMap = { export const eventMapKeys: { [k in keyof DocumentEventMap]?: keyof typeof eventMap } = Object.fromEntries(Object.keys(eventMap).map(k => [k.toLowerCase(), k])) + +function getEventClass(type: EventType) { + const k = eventMapKeys[type] + return k && eventMap[k].EventType +} + +const mouseEvents = ['MouseEvent', 'PointerEvent'] +export function isMouseEvent(type: EventType) { + return mouseEvents.includes(getEventClass(type) as string) +} + +export function isKeyboardEvent(type: EventType) { + return getEventClass(type) === 'KeyboardEvent' +} diff --git a/src/event/eventTypes.ts b/src/event/eventTypes.ts deleted file mode 100644 index 782be400..00000000 --- a/src/event/eventTypes.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {eventMap} from '@testing-library/dom/dist/event-map.js' - -const eventKeys = Object.fromEntries( - Object.keys(eventMap).map(k => [k.toLowerCase(), k]), -) as { - [k in keyof DocumentEventMap]?: keyof typeof eventMap -} - -function getEventClass(type: keyof DocumentEventMap) { - return type in eventKeys - ? eventMap[eventKeys[type] as keyof typeof eventMap].EventType - : 'Event' -} - -const mouseEvents = ['MouseEvent', 'PointerEvent'] -export function isMouseEvent(type: keyof DocumentEventMap) { - return mouseEvents.includes(getEventClass(type)) -} - -export function isKeyboardEvent(type: keyof DocumentEventMap) { - return getEventClass(type) === 'KeyboardEvent' -} diff --git a/src/event/index.ts b/src/event/index.ts index 0c299102..ea96faa1 100644 --- a/src/event/index.ts +++ b/src/event/index.ts @@ -1,8 +1,7 @@ import {Config} from '../setup' -import {getUIEventModifiers} from '../utils' import {createEvent, EventTypeInit} from './createEvent' import {dispatchEvent} from './dispatchEvent' -import {isKeyboardEvent, isMouseEvent} from './eventTypes' +import {isKeyboardEvent, isMouseEvent} from './eventMap' import {EventType, PointerCoords} from './types' export type {EventType, PointerCoords} @@ -17,7 +16,7 @@ export function dispatchUIEvent( if (isMouseEvent(type) || isKeyboardEvent(type)) { init = { ...init, - ...getUIEventModifiers(config.keyboardState), + ...config.system.getUIEventModifiers(), } as EventTypeInit } diff --git a/src/index.ts b/src/index.ts index 30c6eb9a..921c93db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export {userEvent as default} from './setup' -export type {keyboardKey} from './keyboard' -export type {pointerKey} from './pointer' +export type {keyboardKey} from './system/keyboard' +export type {pointerKey} from './system/pointer' export {PointerEventsCheckLevel} from './options' diff --git a/src/keyboard/index.ts b/src/keyboard/index.ts index 7a37a664..aba440a7 100644 --- a/src/keyboard/index.ts +++ b/src/keyboard/index.ts @@ -1,36 +1,54 @@ import {Config, Instance} from '../setup' -import {keyboardAction, KeyboardAction, releaseAllKeys} from './keyboardAction' +import {keyboardKey} from '../system/keyboard' +import {wait} from '../utils' import {parseKeyDef} from './parseKeyDef' -import type {keyboardState, keyboardKey} from './types' -export {releaseAllKeys} -export type {keyboardKey, keyboardState} +interface KeyboardAction { + keyDef: keyboardKey + releasePrevious: boolean + releaseSelf: boolean + repeat: number +} export async function keyboard(this: Instance, text: string): Promise { const actions: KeyboardAction[] = parseKeyDef(this[Config].keyboardMap, text) - return keyboardAction(this[Config], actions) + for (let i = 0; i < actions.length; i++) { + await wait(this[Config]) + + await keyboardAction(this[Config], actions[i]) + } +} + +async function keyboardAction( + config: Config, + {keyDef, releasePrevious, releaseSelf, repeat}: KeyboardAction, +) { + const {system} = config + + // Release the key automatically if it was pressed before. + if (system.keyboard.isKeyPressed(keyDef)) { + await system.keyboard.keyup(config, keyDef) + } + + if (!releasePrevious) { + for (let i = 1; i <= repeat; i++) { + await system.keyboard.keydown(config, keyDef) + + if (i < repeat) { + await wait(config) + } + } + + // Release the key only on the last iteration on `state.repeatKey`. + if (releaseSelf) { + await system.keyboard.keyup(config, keyDef) + } + } } -export function createKeyboardState(): keyboardState { - return { - activeElement: null, - pressed: [], - carryChar: '', - modifiers: { - Alt: false, - AltGraph: false, - Control: false, - CapsLock: false, - Fn: false, - FnLock: false, - Meta: false, - NumLock: false, - ScrollLock: false, - Shift: false, - Symbol: false, - SymbolLock: false, - }, - modifierPhase: {}, +export async function releaseAllKeys(config: Config) { + for (const k of config.system.keyboard.getPressedKeys()) { + await config.system.keyboard.keyup(config, k) } } diff --git a/src/keyboard/keyMap.ts b/src/keyboard/keyMap.ts index b486cbdf..51afcd2f 100644 --- a/src/keyboard/keyMap.ts +++ b/src/keyboard/keyMap.ts @@ -1,4 +1,4 @@ -import {DOM_KEY_LOCATION, keyboardKey} from './types' +import {DOM_KEY_LOCATION, keyboardKey} from '../system/keyboard' /** * Mapping for a default US-104-QWERTY keyboard diff --git a/src/keyboard/keyboardAction.ts b/src/keyboard/keyboardAction.ts deleted file mode 100644 index d4887978..00000000 --- a/src/keyboard/keyboardAction.ts +++ /dev/null @@ -1,148 +0,0 @@ -import {dispatchUIEvent} from '../event' -import {Config} from '../setup' -import {getActiveElement, getKeyEventProps, wait} from '../utils' -import {keyboardKey} from './types' -import { - postKeyupBehavior, - preKeydownBehavior, - preKeyupBehavior, -} from './modifiers' - -export interface KeyboardAction { - keyDef: keyboardKey - releasePrevious: boolean - releaseSelf: boolean - repeat: number -} - -export async function keyboardAction( - config: Config, - actions: KeyboardAction[], -) { - for (let i = 0; i < actions.length; i++) { - await keyboardKeyAction(config, actions[i]) - - if (i < actions.length - 1) { - await wait(config) - } - } -} - -async function keyboardKeyAction( - config: Config, - {keyDef, releasePrevious, releaseSelf, repeat}: KeyboardAction, -) { - const {document, keyboardState} = config - const getCurrentElement = () => getActive(document) - - // Release the key automatically if it was pressed before. - const pressed = keyboardState.pressed.find(p => p.keyDef === keyDef) - if (pressed) { - await keyup(keyDef, getCurrentElement, config, pressed.unpreventedDefault) - } - - if (!releasePrevious) { - let unpreventedDefault = true - for (let i = 1; i <= repeat; i++) { - unpreventedDefault = await keydown(keyDef, getCurrentElement, config) - - if (unpreventedDefault && hasKeyPress(keyDef, config)) { - await keypress(keyDef, getCurrentElement, config) - } - - if (i < repeat) { - await wait(config) - } - } - - // Release the key only on the last iteration on `state.repeatKey`. - if (releaseSelf) { - await keyup(keyDef, getCurrentElement, config, unpreventedDefault) - } - } -} - -function getActive(document: Document): Element { - return getActiveElement(document) ?? /* istanbul ignore next */ document.body -} - -export async function releaseAllKeys(config: Config) { - const getCurrentElement = () => getActive(config.document) - for (const k of config.keyboardState.pressed) { - await keyup(k.keyDef, getCurrentElement, config, k.unpreventedDefault) - } -} - -async function keydown( - keyDef: keyboardKey, - getCurrentElement: () => Element, - config: Config, -) { - const element = getCurrentElement() - - // clear carried characters when focus is moved - if (element !== config.keyboardState.activeElement) { - config.keyboardState.carryValue = undefined - config.keyboardState.carryChar = '' - } - config.keyboardState.activeElement = element - - preKeydownBehavior(config, keyDef, element) - - const unpreventedDefault = dispatchUIEvent( - config, - element, - 'keydown', - getKeyEventProps(keyDef), - ) - - config.keyboardState.pressed.push({keyDef, unpreventedDefault}) - - return unpreventedDefault -} - -async function keypress( - keyDef: keyboardKey, - getCurrentElement: () => Element, - config: Config, -) { - const element = getCurrentElement() - - dispatchUIEvent(config, element, 'keypress', { - ...getKeyEventProps(keyDef), - charCode: keyDef.key === 'Enter' ? 13 : String(keyDef.key).charCodeAt(0), - }) -} - -async function keyup( - keyDef: keyboardKey, - getCurrentElement: () => Element, - config: Config, - unprevented: boolean, -) { - const element = getCurrentElement() - - preKeyupBehavior(config, keyDef) - - dispatchUIEvent( - config, - element, - 'keyup', - getKeyEventProps(keyDef), - !unprevented, - ) - - config.keyboardState.pressed = config.keyboardState.pressed.filter( - k => k.keyDef !== keyDef, - ) - - postKeyupBehavior(config, keyDef, element) -} - -function hasKeyPress(keyDef: keyboardKey, config: Config) { - return ( - (keyDef.key?.length === 1 || keyDef.key === 'Enter') && - !config.keyboardState.modifiers.Control && - !config.keyboardState.modifiers.Alt - ) -} diff --git a/src/keyboard/modifiers.ts b/src/keyboard/modifiers.ts deleted file mode 100644 index 4cdb0337..00000000 --- a/src/keyboard/modifiers.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * This file should contain behavior for modifier keys: - * https://www.w3.org/TR/uievents-key/#keys-modifier - */ - -import {dispatchUIEvent} from '../event' -import {getKeyEventProps} from '../utils' -import {Config} from '../setup' -import {keyboardKey} from '.' - -const modifierKeys = [ - 'Alt', - 'AltGraph', - 'Control', - 'Fn', - 'Meta', - 'Shift', - 'Symbol', -] as const -type ModififierKey = typeof modifierKeys[number] - -function isModifierKey(key?: string): key is ModififierKey { - return modifierKeys.includes(key as ModififierKey) -} - -const modifierLocks = [ - 'CapsLock', - 'FnLock', - 'NumLock', - 'ScrollLock', - 'SymbolLock', -] as const -type ModififierLockKey = typeof modifierLocks[number] - -function isModifierLock(key?: string): key is ModififierLockKey { - return modifierLocks.includes(key as ModififierLockKey) -} - -// modifierKeys switch on the modifier BEFORE the keydown event -export function preKeydownBehavior( - config: Config, - {key}: keyboardKey, - element: Element, -) { - if (isModifierKey(key)) { - config.keyboardState.modifiers[key] = true - - // AltGraph produces an extra keydown for Control - // The modifier does not change - if (key === 'AltGraph') { - const ctrlKeyDef = config.keyboardMap.find( - k => k.key === 'Control', - ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - dispatchUIEvent(config, element, 'keydown', getKeyEventProps(ctrlKeyDef)) - } - } else if (isModifierLock(key)) { - config.keyboardState.modifierPhase[key] = - config.keyboardState.modifiers[key] - - if (!config.keyboardState.modifierPhase[key]) { - config.keyboardState.modifiers[key] = true - } - } -} - -// modifierKeys switch off the modifier BEFORE the keyup event -export function preKeyupBehavior(config: Config, {key}: keyboardKey) { - if (isModifierKey(key)) { - config.keyboardState.modifiers[key] = false - } else if (isModifierLock(key)) { - if (config.keyboardState.modifierPhase[key]) { - config.keyboardState.modifiers[key] = false - } - } -} - -export function postKeyupBehavior( - config: Config, - {key}: keyboardKey, - element: Element, -) { - // AltGraph produces an extra keyup for Control - // The modifier does not change - if (key === 'AltGraph') { - const ctrlKeyDef = config.keyboardMap.find( - k => k.key === 'Control', - ) ?? /* istanbul ignore next */ {key: 'Control', code: 'Control'} - dispatchUIEvent(config, element, 'keyup', getKeyEventProps(ctrlKeyDef)) - } -} diff --git a/src/keyboard/parseKeyDef.ts b/src/keyboard/parseKeyDef.ts index 8fdd1e7d..5c149685 100644 --- a/src/keyboard/parseKeyDef.ts +++ b/src/keyboard/parseKeyDef.ts @@ -1,5 +1,5 @@ +import {keyboardKey} from '../system/keyboard' import {readNextDescriptor} from '../utils' -import {keyboardKey} from './types' /** * Parse key defintions per `keyboardMap` diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts deleted file mode 100644 index 9c3bfe24..00000000 --- a/src/keyboard/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @internal Do not create/alter this by yourself as this type might be subject to changes. - */ -export type keyboardState = { - /** - All keys that have been pressed and not been lifted up yet. - */ - pressed: {keyDef: keyboardKey; unpreventedDefault: boolean}[] - - /** - Active modifiers - */ - modifiers: { - Alt: boolean - AltGraph: boolean - CapsLock: boolean - Control: boolean - Fn: boolean - FnLock: boolean - Meta: boolean - NumLock: boolean - ScrollLock: boolean - Shift: boolean - Symbol: boolean - SymbolLock: boolean - } - modifierPhase: { - CapsLock?: boolean - FnLock?: boolean - NumLock?: boolean - ScrollLock?: boolean - SymbolLock?: boolean - } - - /** - The element the keyboard input is performed on. - Some behavior might differ if the activeElement changes between corresponding keyboard events. - */ - activeElement: Element | null - - /** - For HTMLInputElements type='number': - If the last input char is '.', '-' or 'e', - the IDL value attribute does not reflect the input value. - - @deprecated The document state workaround in `src/document/value.ts` keeps track - of UI value diverging from value property. - */ - carryValue?: string - - /** - Carry over characters to following key handlers. - E.g. ^1 - */ - carryChar: string -} - -export enum DOM_KEY_LOCATION { - STANDARD = 0, - LEFT = 1, - RIGHT = 2, - NUMPAD = 3, -} - -export interface keyboardKey { - /** Physical location on a keyboard */ - code?: string - /** Character or functional key descriptor */ - key?: string - /** Location on the keyboard for keys with multiple representation */ - location?: DOM_KEY_LOCATION - /** Does the character in `key` require/imply AltRight to be pressed? */ - altGr?: boolean - /** Does the character in `key` require/imply a shiftKey to be pressed? */ - shift?: boolean -} diff --git a/src/options.ts b/src/options.ts index 3bd83aea..81f01a29 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,7 +1,7 @@ -import type {keyboardKey} from './keyboard/types' -import type {pointerKey} from './pointer/types' 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' export enum PointerEventsCheckLevel { /** diff --git a/src/pointer/firePointerEvents.ts b/src/pointer/firePointerEvents.ts deleted file mode 100644 index aa1e6729..00000000 --- a/src/pointer/firePointerEvents.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {dispatchUIEvent, EventType, PointerCoords} from '../event' -import {Config} from '../setup' -import {getMouseButton, getMouseButtons, MouseButton} from '../utils' - -export function firePointerEvent( - config: Config, - target: Element, - type: EventType, - { - pointerType, - button, - coords, - pointerId, - isPrimary, - clickCount, - }: { - pointerType?: 'mouse' | 'pen' | 'touch' - button?: MouseButton - coords?: PointerCoords - pointerId?: number - isPrimary?: boolean - clickCount?: number - }, -) { - const init: MouseEventInit & PointerEventInit = { - ...coords, - } - if (type === 'click' || type.startsWith('pointer')) { - init.pointerId = pointerId - init.pointerType = pointerType - } - if (['pointerdown', 'pointerup'].includes(type)) { - init.isPrimary = isPrimary - } - init.button = getMouseButton(button ?? 0) - init.buttons = getMouseButtons( - ...config.pointerState.pressed - .filter(p => p.keyDef.pointerType === pointerType) - .map(p => p.keyDef.button ?? 0), - ) - if ( - ['mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu'].includes(type) - ) { - init.detail = clickCount - } - - return dispatchUIEvent(config, target, type, init) -} diff --git a/src/pointer/index.ts b/src/pointer/index.ts index 313c3073..c8354aff 100644 --- a/src/pointer/index.ts +++ b/src/pointer/index.ts @@ -1,20 +1,38 @@ +import {PointerCoords} from '../event' import {Config, Instance} from '../setup' +import {pointerKey, PointerPosition} from '../system/pointer' +import {ApiLevel, setLevelRef, wait} from '../utils' import {parseKeyDef} from './parseKeyDef' -import { - pointerAction, - PointerAction, - PointerActionTarget, -} from './pointerAction' -import type {pointerState, pointerKey} from './types' - -export type {pointerState, pointerKey} type PointerActionInput = | string - | ({keys: string} & PointerActionTarget) + | ({keys: string} & PointerActionPosition) | PointerAction export type PointerInput = PointerActionInput | Array +type PointerAction = PointerPressAction | PointerMoveAction + +type PointerActionPosition = { + target?: Element + coords?: PointerCoords + node?: Node + /** + * If `node` is set, this is the DOM offset. + * Otherwise this is the `textContent`/`value` offset on the `target`. + */ + offset?: number +} + +interface PointerPressAction extends PointerActionPosition { + keyDef: pointerKey + releasePrevious: boolean + releaseSelf: boolean +} + +interface PointerMoveAction extends PointerActionPosition { + pointerName?: string +} + export async function pointer( this: Instance, input: PointerInput, @@ -37,29 +55,71 @@ export async function pointer( } }) - return pointerAction(this[Config], actions).then(() => undefined) + for (let i = 0; i < actions.length; i++) { + await wait(this[Config]) + + await pointerAction(this[Config], actions[i]) + } + + this[Config].system.pointer.resetClickCount() } -export function createPointerState(document: Document): pointerState { - return { - pointerId: 1, - position: { - mouse: { - pointerType: 'mouse', - pointerId: 1, - target: document.body, - coords: { - clientX: 0, - clientY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - x: 0, - y: 0, - }, - }, +async function pointerAction(config: Config, action: PointerAction) { + const pointerName = + 'pointerName' in action && action.pointerName + ? action.pointerName + : 'keyDef' in action + ? config.system.pointer.getPointerName(action.keyDef) + : 'mouse' + + const previousPosition = + config.system.pointer.getPreviousPosition(pointerName) + const position: PointerPosition = { + target: action.target ?? getPrevTarget(config, previousPosition), + coords: action.coords ?? previousPosition?.coords, + caret: { + node: + action.node ?? + (hasCaretPosition(action) ? undefined : previousPosition?.caret?.node), + offset: + action.offset ?? + (hasCaretPosition(action) + ? undefined + : previousPosition?.caret?.offset), }, - pressed: [], } + + 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 (!action.releasePrevious) { + setLevelRef(config, ApiLevel.Trigger) + await config.system.pointer.press(config, action.keyDef, position) + + if (action.releaseSelf) { + setLevelRef(config, ApiLevel.Trigger) + await config.system.pointer.release(config, action.keyDef, position) + } + } + } else { + setLevelRef(config, ApiLevel.Trigger) + await config.system.pointer.move(config, pointerName, position) + } +} + +function hasCaretPosition(action: PointerAction) { + return !!(action.target ?? action.node ?? action.offset !== undefined) +} + +function getPrevTarget(config: Config, position?: PointerPosition) { + if (!position) { + throw new Error( + 'This pointer has no previous position. Provide a target property!', + ) + } + + return position.target ?? config.document.body } diff --git a/src/pointer/keyMap.ts b/src/pointer/keyMap.ts index e194dea4..8bdebe61 100644 --- a/src/pointer/keyMap.ts +++ b/src/pointer/keyMap.ts @@ -1,4 +1,4 @@ -import {pointerKey} from './types' +import {pointerKey} from '../system/pointer' export const defaultKeyMap: pointerKey[] = [ {name: 'MouseLeft', pointerType: 'mouse', button: 'primary'}, diff --git a/src/pointer/parseKeyDef.ts b/src/pointer/parseKeyDef.ts index 6c5de511..274561ae 100644 --- a/src/pointer/parseKeyDef.ts +++ b/src/pointer/parseKeyDef.ts @@ -1,5 +1,5 @@ +import {pointerKey} from '../system/pointer' import {readNextDescriptor} from '../utils' -import {pointerKey} from './types' export function parseKeyDef(pointerMap: pointerKey[], keys: string) { const defs: Array<{ diff --git a/src/pointer/pointerAction.ts b/src/pointer/pointerAction.ts deleted file mode 100644 index 7ca73410..00000000 --- a/src/pointer/pointerAction.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {Config} from '../setup' -import {wait} from '../utils' -import {pointerMove, PointerMoveAction} from './pointerMove' -import {pointerPress, PointerPressAction} from './pointerPress' -import {pointerState, PointerTarget, SelectionTarget} from './types' - -export type PointerActionTarget = Partial & - Partial - -export type PointerAction = PointerActionTarget & - ( - | Omit - | Omit - ) - -export async function pointerAction(config: Config, actions: PointerAction[]) { - for (let i = 0; i < actions.length; i++) { - const action = actions[i] - const pointerName = - 'pointerName' in action && action.pointerName - ? action.pointerName - : 'keyDef' in action - ? action.keyDef.pointerType === 'touch' - ? action.keyDef.name - : action.keyDef.pointerType - : 'mouse' - - const target = - action.target ?? getPrevTarget(pointerName, config.pointerState) - const coords = - action.coords ?? - (pointerName in config.pointerState.position - ? config.pointerState.position[pointerName].coords - : undefined) - - await ('keyDef' in action - ? pointerPress(config, {...action, target, coords}) - : pointerMove(config, {...action, target, coords})) - - if (i < actions.length - 1) { - await wait(config) - } - } - - delete config.pointerState.activeClickCount -} - -function getPrevTarget(pointerName: string, state: pointerState) { - if (!(pointerName in state.position) || !state.position[pointerName].target) { - throw new Error( - 'This pointer has no previous position. Provide a target property!', - ) - } - - return state.position[pointerName].target as Element -} diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts deleted file mode 100644 index 492d48b6..00000000 --- a/src/pointer/pointerMove.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {setUISelection} from '../document' -import {EventType, PointerCoords} from '../event' -import {Config} from '../setup' -import { - isDescendantOrSelf, - isDisabled, - assertPointerEvents, - setLevelRef, - ApiLevel, - hasPointerEvents, -} from '../utils' -import {firePointerEvent} from './firePointerEvents' -import {resolveSelectionTarget} from './resolveSelectionTarget' -import {PointerTarget, SelectionTarget} from './types' - -export interface PointerMoveAction extends PointerTarget, SelectionTarget { - pointerName?: string -} - -export async function pointerMove( - config: Config, - {pointerName = 'mouse', target, coords, node, offset}: PointerMoveAction, -): Promise { - const {pointerState} = config - if (!(pointerName in pointerState.position)) { - throw new Error( - `Trying to move pointer "${pointerName}" which does not exist.`, - ) - } - - const { - pointerId, - pointerType, - target: prevTarget, - coords: prevCoords, - selectionRange, - } = pointerState.position[pointerName] - - if (prevTarget && prevTarget !== target) { - setLevelRef(config, ApiLevel.Trigger) - if (hasPointerEvents(config, prevTarget)) { - // Here we could probably calculate a few coords to a fake boundary(?) - fireMove(prevTarget, prevCoords) - - if (!isDescendantOrSelf(target, prevTarget)) { - fireLeave(prevTarget, prevCoords) - } - } - } - - setLevelRef(config, ApiLevel.Trigger) - assertPointerEvents(config, target) - - pointerState.position[pointerName] = { - ...pointerState.position[pointerName], - target, - coords, - } - - if (prevTarget !== target) { - if (!prevTarget || !isDescendantOrSelf(prevTarget, target)) { - fireEnter(target, coords) - } - } - - // TODO: drag if the target didn't change? - - // Here we could probably calculate a few coords leading up to the final position - fireMove(target, coords) - - if (selectionRange) { - // TODO: support extending range (shift) - - const selectionFocus = resolveSelectionTarget({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 = target.ownerDocument.getSelection() as Selection - selection.removeAllRanges() - selection.addRange(range.cloneRange()) - } - } - - function fireMove(eventTarget: Element, eventCoords?: PointerCoords) { - fire(eventTarget, 'pointermove', eventCoords) - if (pointerType === 'mouse' && !isDisabled(eventTarget)) { - fire(eventTarget, 'mousemove', eventCoords) - } - } - - function fireLeave(eventTarget: Element, eventCoords?: PointerCoords) { - fire(eventTarget, 'pointerout', eventCoords) - fire(eventTarget, 'pointerleave', eventCoords) - if (pointerType === 'mouse' && !isDisabled(eventTarget)) { - fire(eventTarget, 'mouseout', eventCoords) - fire(eventTarget, 'mouseleave', eventCoords) - } - } - - function fireEnter(eventTarget: Element, eventCoords?: PointerCoords) { - fire(eventTarget, 'pointerover', eventCoords) - fire(eventTarget, 'pointerenter', eventCoords) - if (pointerType === 'mouse' && !isDisabled(eventTarget)) { - fire(eventTarget, 'mouseover', eventCoords) - fire(eventTarget, 'mouseenter', eventCoords) - } - } - - function fire( - eventTarget: Element, - type: EventType, - eventCoords?: PointerCoords, - ) { - return firePointerEvent(config, eventTarget, type, { - coords: eventCoords, - pointerId, - pointerType, - }) - } -} diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts deleted file mode 100644 index 8730f917..00000000 --- a/src/pointer/pointerPress.ts +++ /dev/null @@ -1,361 +0,0 @@ -/* eslint-disable complexity */ - -import { - ApiLevel, - assertPointerEvents, - focus, - isDisabled, - isElementType, - setLevelRef, -} from '../utils' -import {getUIValue, setUISelection} from '../document' -import {EventType} from '../event' -import {Config} from '../setup' -import type { - pointerKey, - pointerState, - PointerTarget, - SelectionTarget, -} from './types' -import {resolveSelectionTarget} from './resolveSelectionTarget' -import {firePointerEvent} from './firePointerEvents' - -export interface PointerPressAction extends PointerTarget, SelectionTarget { - keyDef: pointerKey - releasePrevious: boolean - releaseSelf: boolean -} - -export async function pointerPress( - config: Config, - action: PointerPressAction, -): Promise { - const {keyDef, target, releasePrevious, releaseSelf} = action - const previous = config.pointerState.pressed.find(p => p.keyDef === keyDef) - - const pointerName = - keyDef.pointerType === 'touch' ? keyDef.name : keyDef.pointerType - - const targetIsDisabled = isDisabled(target) - - if (previous) { - up(config, pointerName, action, previous, targetIsDisabled) - } - - if (!releasePrevious) { - const press = down(config, pointerName, action, targetIsDisabled) - - if (releaseSelf) { - up(config, pointerName, action, press, targetIsDisabled) - } - } -} - -function getNextPointerId(state: pointerState) { - state.pointerId = state.pointerId + 1 - return state.pointerId -} - -function down( - config: Config, - pointerName: string, - {keyDef, node, offset, target, coords}: PointerPressAction, - targetIsDisabled: boolean, -) { - setLevelRef(config, ApiLevel.Trigger) - assertPointerEvents(config, target) - - const {pointerState} = config - const {name, pointerType, button} = keyDef - const pointerId = pointerType === 'mouse' ? 1 : getNextPointerId(pointerState) - - pointerState.position[pointerName] = { - ...pointerState.position[pointerName], - pointerId, - pointerType, - target, - coords, - } - - let isMultiTouch = false - let isPrimary = true - if (pointerType !== 'mouse') { - for (const obj of pointerState.pressed) { - // TODO: test multi device input across browsers - // istanbul ignore else - if (obj.keyDef.pointerType === pointerType) { - obj.isMultiTouch = true - isMultiTouch = true - isPrimary = false - } - } - } - - if (pointerState.activeClickCount?.[0] !== name) { - delete pointerState.activeClickCount - } - const clickCount = Number(pointerState.activeClickCount?.[1] ?? 0) + 1 - pointerState.activeClickCount = [name, clickCount] - - const pressObj = { - keyDef, - downTarget: target, - pointerId, - unpreventedDefault: true, - isMultiTouch, - isPrimary, - clickCount, - } - pointerState.pressed.push(pressObj) - - if (pointerType !== 'mouse') { - fire('pointerover') - fire('pointerenter') - } - if ( - pointerType !== 'mouse' || - !pointerState.pressed.some( - p => p.keyDef !== keyDef && p.keyDef.pointerType === pointerType, - ) - ) { - fire('pointerdown') - } - - // TODO: touch... - - if (pointerType === 'mouse') { - if (!targetIsDisabled) { - pressObj.unpreventedDefault = fire('mousedown') - } - - if (pressObj.unpreventedDefault) { - mousedownDefaultBehavior({ - target, - targetIsDisabled, - clickCount, - position: pointerState.position[pointerName], - node, - offset, - }) - } - - if (button === 'secondary') { - fire('contextmenu') - } - } - - return pressObj - - function fire(type: EventType) { - return firePointerEvent(config, target, type, { - button, - clickCount, - coords, - isPrimary, - pointerId, - pointerType, - }) - } -} - -function up( - config: Config, - pointerName: string, - { - keyDef: {pointerType, button}, - target, - coords, - node, - offset, - }: PointerPressAction, - pressed: pointerState['pressed'][number], - targetIsDisabled: boolean, -) { - setLevelRef(config, ApiLevel.Trigger) - assertPointerEvents(config, target) - - const {pointerState} = config - pointerState.pressed = pointerState.pressed.filter(p => p !== pressed) - - const {isMultiTouch, isPrimary, pointerId, clickCount} = pressed - let {unpreventedDefault} = pressed - - pointerState.position[pointerName] = { - ...pointerState.position[pointerName], - target, - coords, - } - - // TODO: pointerleave for touch device - - if ( - pointerType !== 'mouse' || - !pointerState.pressed.filter(p => p.keyDef.pointerType === pointerType) - .length - ) { - fire('pointerup') - } - if (pointerType !== 'mouse') { - fire('pointerout') - fire('pointerleave') - } - - if (pointerType !== 'mouse' && !isMultiTouch) { - if (!targetIsDisabled) { - if (clickCount === 1) { - fire('mouseover') - fire('mouseenter') - } - fire('mousemove') - unpreventedDefault = fire('mousedown') && unpreventedDefault - } - - if (unpreventedDefault) { - mousedownDefaultBehavior({ - target, - targetIsDisabled, - clickCount, - position: pointerState.position[pointerName], - node, - offset, - }) - } - } - - delete pointerState.position[pointerName].selectionRange - - if (!targetIsDisabled) { - if (pointerType === 'mouse' || !isMultiTouch) { - unpreventedDefault = fire('mouseup') && unpreventedDefault - - const canClick = pointerType !== 'mouse' || button === 'primary' - if (canClick && target === pressed.downTarget) { - fire('click') - - if (clickCount === 2) { - fire('dblclick') - } - } - } - } - - function fire(type: EventType) { - return firePointerEvent(config, target, type, { - button, - clickCount, - coords, - isPrimary, - pointerId, - pointerType, - }) - } -} - -function mousedownDefaultBehavior({ - position, - target, - targetIsDisabled, - clickCount, - node, - offset, -}: { - position: NonNullable - target: Element - targetIsDisabled: boolean - clickCount: number - node?: Node - offset?: number -}) { - // An unprevented mousedown moves the cursor to the closest character. - // We try to approximate the behavior for a no-layout environment. - if (!targetIsDisabled) { - const hasValue = isElementType(target, ['input', 'textarea']) - - // 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(hasValue ? 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 (hasValue) { - setUISelection(target, { - anchorOffset: start ?? text.length, - focusOffset: end ?? text.length, - }) - position.selectionRange = { - node: target, - start: start ?? 0, - end: end ?? text.length, - } - } else { - const {node: startNode, offset: startOffset} = resolveSelectionTarget({ - target, - node, - offset: start, - }) - const {node: endNode, offset: endOffset} = resolveSelectionTarget({ - target, - node, - offset: end, - }) - - const range = target.ownerDocument.createRange() - range.setStart(startNode, startOffset) - range.setEnd(endNode, endOffset) - - position.selectionRange = range - - const selection = target.ownerDocument.getSelection() as Selection - selection.removeAllRanges() - selection.addRange(range.cloneRange()) - } - } - - // The closest focusable element is focused when a `mousedown` would have been fired. - // Even if there was no `mousedown` because the element was disabled. - // A `mousedown` that preventsDefault cancels this though. - focus(target) -} - -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/pointer/types.ts b/src/pointer/types.ts deleted file mode 100644 index 3ba057d4..00000000 --- a/src/pointer/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {PointerCoords} from '../event' -import {MouseButton} from '../utils' - -/** - * @internal Do not create/alter this by yourself as this type might be subject to changes. - */ -export type pointerState = { - /** - All keys that have been pressed and not been lifted up yet. - */ - pressed: { - keyDef: pointerKey - pointerId: number - isMultiTouch: boolean - isPrimary: boolean - clickCount: number - unpreventedDefault: boolean - /** Target the key was pressed on */ - downTarget: Element - }[] - - activeClickCount?: [string, number] - - /** - * Position of each pointer. - * The mouse is always pointer 1 and keeps its position. - * Pen and touch devices receive a new pointerId for every interaction. - */ - position: Record< - string, - { - pointerId: number - pointerType: 'mouse' | 'pen' | 'touch' - } & Partial & { - selectionRange?: Range | SelectionInputRange - } - > - - /** - * Last applied pointer id - */ - pointerId: number -} - -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 PointerTarget { - target: Element - coords?: PointerCoords -} - -export interface SelectionTarget { - node?: Node - /** - * If `node` is set, this is the DOM offset. - * Otherwise this is the `textContent`/`value` offset on the `target`. - */ - offset?: number -} - -/** Describes a selection inside `HTMLInputElement`/`HTMLTextareaElement` */ -export interface SelectionInputRange { - node: HTMLInputElement | HTMLTextAreaElement - start: number - end: number -} diff --git a/src/setup/config.ts b/src/setup/config.ts index ad70e929..d4a920ae 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -1,11 +1,7 @@ -import type {keyboardState} from '../keyboard/types' -import type {pointerState} from '../pointer/types' import type {Options} from '../options' +import {System} from '../system' -export interface inputDeviceState { - pointerState: pointerState - keyboardState: keyboardState +export interface Config extends Required { + system: System } - -export interface Config extends Required, inputDeviceState {} export const Config = Symbol('Config') diff --git a/src/setup/directApi.ts b/src/setup/directApi.ts index e966b88f..f4da5ebc 100644 --- a/src/setup/directApi.ts +++ b/src/setup/directApi.ts @@ -1,58 +1,64 @@ +import type {Options} from '../options' import type {PointerInput} from '../pointer' +import type {System} from '../system' import type {UserEventApi} from '.' import {setupDirect} from './setup' -import {Config} from './config' + +export type DirectOptions = Options & { + keyboardState?: System + pointerState?: System +} export function clear(element: Element) { return setupDirect().api.clear(element) } -export function click(element: Element, options: Partial = {}) { +export function click(element: Element, options: DirectOptions = {}) { return setupDirect(options, element).api.click(element) } -export function copy(options: Partial = {}) { +export function copy(options: DirectOptions = {}) { return setupDirect(options).api.copy() } -export function cut(options: Partial = {}) { +export function cut(options: DirectOptions = {}) { return setupDirect(options).api.cut() } -export function dblClick(element: Element, options: Partial = {}) { +export function dblClick(element: Element, options: DirectOptions = {}) { return setupDirect(options).api.dblClick(element) } export function deselectOptions( select: Element, values: HTMLElement | HTMLElement[] | string[] | string, - options: Partial = {}, + options: DirectOptions = {}, ) { return setupDirect(options).api.deselectOptions(select, values) } -export function hover(element: Element, options: Partial = {}) { +export function hover(element: Element, options: DirectOptions = {}) { return setupDirect(options).api.hover(element) } -export async function keyboard(text: string, options: Partial = {}) { +export async function keyboard(text: string, options: DirectOptions = {}) { const {config, api} = setupDirect(options) - return api.keyboard(text).then(() => config.keyboardState) + return api.keyboard(text).then(() => config.system) } export async function pointer( input: PointerInput, - options: Partial = {}, + options: DirectOptions = {}, ) { const {config, api} = setupDirect(options) - return api.pointer(input).then(() => config.pointerState) + return api.pointer(input).then(() => config.system) } export function paste( clipboardData?: DataTransfer | string, - options?: Partial, + options?: DirectOptions, ) { return setupDirect(options).api.paste(clipboardData) } @@ -60,26 +66,26 @@ export function paste( export function selectOptions( select: Element, values: HTMLElement | HTMLElement[] | string[] | string, - options: Partial = {}, + options: DirectOptions = {}, ) { return setupDirect(options).api.selectOptions(select, values) } -export function tripleClick(element: Element, options: Partial = {}) { +export function tripleClick(element: Element, options: DirectOptions = {}) { return setupDirect(options).api.tripleClick(element) } export function type( element: Element, text: string, - options: Partial & Parameters[2] = {}, + options: DirectOptions & Parameters[2] = {}, ) { return setupDirect(options, element).api.type(element, text, options) } -export function unhover(element: Element, options: Partial = {}) { +export function unhover(element: Element, options: DirectOptions = {}) { const {config, api} = setupDirect(options) - config.pointerState.position.mouse.target = element + config.system.pointer.setMousePosition({target: element}) return api.unhover(element) } @@ -87,13 +93,13 @@ export function unhover(element: Element, options: Partial = {}) { export function upload( element: HTMLElement, fileOrFiles: File | File[], - options: Partial = {}, + options: DirectOptions = {}, ) { return setupDirect(options).api.upload(element, fileOrFiles) } export function tab( - options: Partial & Parameters[0] = {}, + options: DirectOptions & Parameters[0] = {}, ) { return setupDirect().api.tab(options) } diff --git a/src/setup/index.ts b/src/setup/index.ts index a1e15f3f..7f7193d0 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,10 +1,9 @@ import type {bindDispatchUIEvent} from '../event' import type * as userEventApi from './api' import {setupMain, setupSub} from './setup' -import {Config, inputDeviceState} from './config' +import {Config} from './config' import * as directApi from './directApi' -export type {inputDeviceState} export {Config} export type UserEventApi = typeof userEventApi diff --git a/src/setup/setup.ts b/src/setup/setup.ts index b2f7cb3f..3158f213 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -1,7 +1,5 @@ import {prepareDocument} from '../document' import {bindDispatchUIEvent} from '../event' -import {createKeyboardState} from '../keyboard' -import {createPointerState} from '../pointer' import {defaultOptionsDirect, defaultOptionsSetup, Options} from '../options' import { ApiLevel, @@ -10,10 +8,12 @@ import { setLevelRef, 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' export function createConfig( options: Partial = {}, @@ -22,17 +22,11 @@ export function createConfig( ): Config { const document = getDocument(options, node, defaults) - const { - keyboardState = createKeyboardState(), - pointerState = createPointerState(document), - } = options - return { ...defaults, ...options, document, - keyboardState, - pointerState, + system: options.system ?? new System(), } } @@ -54,8 +48,23 @@ export function setupMain(options: Options = {}) { /** * Setup in direct call per `userEvent.anyApi()` */ -export function setupDirect(options: Partial = {}, node?: Node) { - const config = createConfig(options, defaultOptionsDirect, node) +export function setupDirect( + { + keyboardState, + pointerState, + ...options + }: DirectOptions & // backward-compatibility + {keyboardState?: System; pointerState?: System} = {}, + node?: Node, +) { + const config = createConfig( + { + ...options, + system: pointerState ?? keyboardState, + }, + defaultOptionsDirect, + node, + ) prepareDocument(config.document) return { diff --git a/src/system/index.ts b/src/system/index.ts new file mode 100644 index 00000000..ac2a104a --- /dev/null +++ b/src/system/index.ts @@ -0,0 +1,27 @@ +import {KeyboardHost} from './keyboard' +import {PointerHost} from './pointer' + +/** + * @internal Do not create/alter this by yourself as this type might be subject to changes. + */ +export class System { + readonly keyboard = new KeyboardHost(this) + readonly pointer = new PointerHost(this) + + getUIEventModifiers(): EventModifierInit { + return { + altKey: this.keyboard.modifiers.Alt, + ctrlKey: this.keyboard.modifiers.Control, + metaKey: this.keyboard.modifiers.Meta, + shiftKey: this.keyboard.modifiers.Shift, + modifierAltGraph: this.keyboard.modifiers.AltGraph, + modifierCapsLock: this.keyboard.modifiers.CapsLock, + modifierFn: this.keyboard.modifiers.Fn, + modifierFnLock: this.keyboard.modifiers.FnLock, + modifierNumLock: this.keyboard.modifiers.NumLock, + modifierScrollLock: this.keyboard.modifiers.ScrollLock, + modifierSymbol: this.keyboard.modifiers.Symbol, + modifierSymbolLock: this.keyboard.modifiers.SymbolLock, + } + } +} diff --git a/src/system/keyboard.ts b/src/system/keyboard.ts new file mode 100644 index 00000000..a81b3471 --- /dev/null +++ b/src/system/keyboard.ts @@ -0,0 +1,188 @@ +import {dispatchUIEvent} from '../event' +import {Config} from '../setup' +import {getActiveElementOrBody} from '../utils' +import type {System} from '.' + +export enum DOM_KEY_LOCATION { + STANDARD = 0, + LEFT = 1, + RIGHT = 2, + NUMPAD = 3, +} + +export interface keyboardKey { + /** Physical location on a keyboard */ + code?: string + /** Character or functional key descriptor */ + key?: string + /** Location on the keyboard for keys with multiple representation */ + location?: DOM_KEY_LOCATION + /** Does the character in `key` require/imply AltRight to be pressed? */ + altGr?: boolean + /** Does the character in `key` require/imply a shiftKey to be pressed? */ + shift?: boolean +} + +const modifierKeys = [ + 'Alt', + 'AltGraph', + 'Control', + 'Fn', + 'Meta', + 'Shift', + 'Symbol', +] as const +type ModififierKey = typeof modifierKeys[number] + +function isModifierKey(key?: string): key is ModififierKey { + return modifierKeys.includes(key as ModififierKey) +} + +const modifierLocks = [ + 'CapsLock', + 'FnLock', + 'NumLock', + 'ScrollLock', + 'SymbolLock', +] as const +type ModififierLockKey = typeof modifierLocks[number] + +function isModifierLock(key?: string): key is ModififierLockKey { + return modifierLocks.includes(key as ModififierLockKey) +} + +export class KeyboardHost { + readonly system: System + + constructor(system: System) { + this.system = system + } + readonly modifiers = { + Alt: false, + AltGraph: false, + CapsLock: false, + Control: false, + Fn: false, + FnLock: false, + Meta: false, + NumLock: false, + ScrollLock: false, + Shift: false, + Symbol: false, + SymbolLock: false, + } + readonly pressed: Record< + string, + { + keyDef: keyboardKey + unpreventedDefault: boolean + } + > = {} + carryChar = '' + private lastKeydownTarget: Element | undefined = undefined + private readonly modifierLockStart: Record = {} + + isKeyPressed(keyDef: keyboardKey) { + return !!this.pressed[String(keyDef.code)] + } + + getPressedKeys() { + return Object.values(this.pressed).map(p => p.keyDef) + } + + /** Press a key */ + async keydown(config: Config, keyDef: keyboardKey) { + const key = String(keyDef.key) + const code = String(keyDef.code) + + const target = getActiveElementOrBody(config.document) + this.setKeydownTarget(target) + + this.pressed[code] ??= { + keyDef, + unpreventedDefault: false, + } + + if (isModifierKey(key)) { + this.modifiers[key] = true + } + + const unprevented = dispatchUIEvent(config, target, 'keydown', { + key, + code, + }) + + if (isModifierLock(key) && !this.modifiers[key]) { + this.modifiers[key] = true + this.modifierLockStart[key] = true + } + + this.pressed[code].unpreventedDefault ||= unprevented + + if (unprevented && this.hasKeyPress(key)) { + dispatchUIEvent( + config, + getActiveElementOrBody(config.document), + 'keypress', + { + key, + code, + charCode: + keyDef.key === 'Enter' ? 13 : String(keyDef.key).charCodeAt(0), + }, + ) + } + } + + /** Release a key */ + async keyup(config: Config, keyDef: keyboardKey) { + const key = String(keyDef.key) + const code = String(keyDef.code) + + const unprevented = this.pressed[code].unpreventedDefault + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.pressed[code] + + if ( + isModifierKey(key) && + !Object.values(this.pressed).find(p => p.keyDef.key === key) + ) { + this.modifiers[key] = false + } + + dispatchUIEvent( + config, + getActiveElementOrBody(config.document), + 'keyup', + { + key, + code, + }, + !unprevented, + ) + + if (isModifierLock(key) && this.modifiers[key]) { + if (this.modifierLockStart[key]) { + this.modifierLockStart[key] = false + } else { + this.modifiers[key] = false + } + } + } + + private setKeydownTarget(target: Element) { + if (target !== this.lastKeydownTarget) { + this.carryChar = '' + } + this.lastKeydownTarget = target + } + + private hasKeyPress(key: string) { + return ( + (key.length === 1 || key === 'Enter') && + !this.modifiers.Control && + !this.modifiers.Alt + ) + } +} diff --git a/src/system/pointer/buttons.ts b/src/system/pointer/buttons.ts new file mode 100644 index 00000000..d125e4aa --- /dev/null +++ b/src/system/pointer/buttons.ts @@ -0,0 +1,74 @@ +import type {pointerKey} from '.' + +export class Buttons { + private readonly pressed: Record = {} + + getButtons() { + let v = 0 + for (const button of Object.keys(this.pressed)) { + // eslint-disable-next-line no-bitwise + v |= 2 ** Number(button) + } + return v + } + + down(keyDef: pointerKey) { + const button = getMouseButtonId(keyDef.button) + + if (button in this.pressed) { + this.pressed[button].push(keyDef) + return undefined + } + + this.pressed[button] = [keyDef] + return button + } + + up(keyDef: pointerKey) { + const button = getMouseButtonId(keyDef.button) + + if (button in this.pressed) { + this.pressed[button] = this.pressed[button].filter( + k => k.name !== keyDef.name, + ) + if (this.pressed[button].length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.pressed[button] + return button + } + } + + return undefined + } +} + +export const MouseButton = { + primary: 0, + secondary: 1, + auxiliary: 2, + back: 3, + X1: 3, + forward: 4, + X2: 4, +} as const +export type MouseButton = keyof typeof MouseButton | number + +export function getMouseButtonId(button: MouseButton = 0): number { + if (button in MouseButton) { + return MouseButton[button as keyof typeof MouseButton] + } + return Number(button) +} + +// On the `MouseEvent.button` property auxiliary and secondary button are flipped compared to `MouseEvent.buttons`. +export const MouseButtonFlip = { + 1: 2, + 2: 1, +} as const +export function getMouseEventButton(button?: MouseButton): number { + button = getMouseButtonId(button) + if (button in MouseButtonFlip) { + return MouseButtonFlip[button as keyof typeof MouseButtonFlip] + } + return button +} diff --git a/src/system/pointer/device.ts b/src/system/pointer/device.ts new file mode 100644 index 00000000..e9fe7091 --- /dev/null +++ b/src/system/pointer/device.ts @@ -0,0 +1,21 @@ +import type {pointerKey} from '.' + +export class Device { + private pressedKeys = new Set() + + get countPressed() { + return this.pressedKeys.size + } + + isPressed(keyDef: pointerKey) { + return this.pressedKeys.has(keyDef.name) + } + + addPressed(keyDef: pointerKey) { + return this.pressedKeys.add(keyDef.name) + } + + removePressed(keyDef: pointerKey) { + return this.pressedKeys.delete(keyDef.name) + } +} diff --git a/src/system/pointer/index.ts b/src/system/pointer/index.ts new file mode 100644 index 00000000..c6bfbf81 --- /dev/null +++ b/src/system/pointer/index.ts @@ -0,0 +1,222 @@ +import {System} from '..' +import {PointerCoords} from '../../event' +import {Config} from '../../setup' +import {Buttons, MouseButton} from './buttons' +import {Device} from './device' +import {Mouse} from './mouse' +import {Pointer} from './pointer' + +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 class PointerHost { + readonly system: System + + constructor(system: System) { + this.system = system + this.buttons = new Buttons() + this.mouse = new Mouse() + } + private readonly mouse + private readonly buttons + + private readonly devices = new (class { + private registry = {} as Record + + get(k: string) { + this.registry[k] ??= new Device() + return this.registry[k] + } + })() + + private readonly pointers = new (class { + private registry = { + mouse: new Pointer({ + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + }), + } as Record + private nextId = 2 + + new(pointerName: string, keyDef: pointerKey) { + const isPrimary = + keyDef.pointerType !== 'touch' || + !Object.values(this.registry).some( + p => p.pointerType === 'touch' && !p.isCancelled, + ) + + if (!isPrimary) { + Object.values(this.registry).forEach(p => { + if (p.pointerType === keyDef.pointerType && !p.isCancelled) { + p.isMultitouch = true + } + }) + } + + this.registry[pointerName] = new Pointer({ + pointerId: this.nextId++, + pointerType: keyDef.pointerType, + isPrimary, + }) + + return this.registry[pointerName] + } + + get(pointerName: string) { + if (!this.has(pointerName)) { + throw new Error( + `Trying to access pointer "${pointerName}" which does not exist.`, + ) + } + return this.registry[pointerName] + } + + has(pointerName: string) { + return pointerName in this.registry + } + })() + + isKeyPressed(keyDef: pointerKey) { + return this.devices.get(keyDef.pointerType).isPressed(keyDef) + } + + async press(config: Config, keyDef: pointerKey, position: PointerPosition) { + const pointerName = this.getPointerName(keyDef) + const pointer = + keyDef.pointerType === 'touch' + ? this.pointers.new(pointerName, keyDef).init(config, position) + : this.pointers.get(pointerName) + + // TODO: deprecate the following implicit setting of position + pointer.position = position + if (pointer.pointerType !== 'touch') { + this.mouse.position = position + } + + this.devices.get(keyDef.pointerType).addPressed(keyDef) + + this.buttons.down(keyDef) + pointer.down(config, keyDef) + + if (pointer.pointerType !== 'touch' && !pointer.isPrevented) { + this.mouse.down(config, keyDef, pointer) + } + } + + async move(config: Config, 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 mousemove = + pointer.pointerType === 'touch' || (pointer.isPrevented && pointer.isDown) + ? undefined + : this.mouse.move(config, position) + + pointermove?.leave() + mousemove?.leave() + pointermove?.enter() + mousemove?.enter() + pointermove?.move() + mousemove?.move() + } + + async release(config: Config, keyDef: pointerKey, position: PointerPosition) { + const device = this.devices.get(keyDef.pointerType) + device.removePressed(keyDef) + + this.buttons.up(keyDef) + + const pointer = this.pointers.get(this.getPointerName(keyDef)) + + // TODO: deprecate the following implicit setting of position + pointer.position = position + if (pointer.pointerType !== 'touch') { + this.mouse.position = position + } + + if (device.countPressed === 0) { + pointer.up(config, keyDef) + } + + if (pointer.pointerType === 'touch') { + pointer.release(config) + } + + if (!pointer.isPrevented) { + if (pointer.pointerType === 'touch' && !pointer.isMultitouch) { + const mousemove = this.mouse.move(config, pointer.position) + mousemove?.leave() + mousemove?.enter() + mousemove?.move() + + this.mouse.down(config, keyDef, pointer) + } + if (!pointer.isMultitouch) { + const mousemove = this.mouse.move(config, pointer.position) + mousemove?.leave() + mousemove?.enter() + mousemove?.move() + + this.mouse.up(config, keyDef, pointer) + } + } + } + + getPointerName(keyDef: pointerKey) { + return keyDef.pointerType === 'touch' ? keyDef.name : keyDef.pointerType + } + + getPreviousPosition(pointerName: string) { + return this.pointers.has(pointerName) + ? this.pointers.get(pointerName).position + : undefined + } + + resetClickCount() { + this.mouse.resetClickCount() + } + + getMouseTarget(config: Config) { + return this.mouse.position.target ?? config.document.body + } + + setMousePosition(position: PointerPosition) { + this.mouse.position = position + 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 new file mode 100644 index 00000000..14a90581 --- /dev/null +++ b/src/system/pointer/mouse.ts @@ -0,0 +1,236 @@ +import {dispatchUIEvent, EventType} from '../../event' +import {Config} from '../../setup' +import { + focus, + getTreeDiff, + isDisabled, + modifySelectionPerMouseMove, + SelectionRange, + setSelectionPerMouseDown, +} from '../../utils' +import {isDifferentPointerPosition, pointerKey, PointerPosition} from '.' +import {Buttons, getMouseEventButton, MouseButton} from './buttons' +import type {Pointer} from './pointer' + +/** + * This object is the single "virtual" mouse that might be controlled by multiple different pointer devices. + */ +export class Mouse { + position: PointerPosition = {} + private readonly buttons = new Buttons() + private selecting?: Range | SelectionRange + private buttonDownTarget = {} as Record + + // According to spec the `detail` on click events should be the number + // of *consecutive* clicks with a specific button. + // On `mousedown` and `mouseup` it should be this number increased by one. + // But the browsers don't implement it this way. + // If another button is pressed, + // in Webkit: the `mouseup` on the previously pressed button has `detail: 0` and no `click`/`auxclick`. + // in Gecko: the `mouseup` and click events have the same detail as the `mousedown`. + // If there is a delay while a button is pressed, + // the `mouseup` and `click` are normal, but a following `mousedown` starts a new click count. + // We'll follow the minimal implementation of Webkit. + private readonly clickCount = new (class { + private down: Record = {} + private count: Record = {} + + incOnClick(button: number) { + const current = + this.down[button] === undefined + ? undefined + : Number(this.down[button]) + 1 + + this.count = + this.count[button] === undefined + ? {} + : {[button]: Number(this.count[button]) + 1} + + return current + } + + getOnDown(button: number) { + this.down = {[button]: this.count[button] ?? 0} + this.count = {[button]: this.count[button] ?? 0} + + return Number(this.count[button]) + 1 + } + + getOnUp(button: number) { + return this.down[button] === undefined + ? undefined + : Number(this.down[button]) + 1 + } + + reset() { + this.count = {} + } + })() + + move(config: Config, position: PointerPosition) { + const prevPosition = this.position + const prevTarget = this.getTarget(config) + + this.position = position + + if (!isDifferentPointerPosition(prevPosition, position)) { + return + } + + const nextTarget = this.getTarget(config) + + const init = this.getEventInit('mousemove') + + const [leave, enter] = getTreeDiff(prevTarget, nextTarget) + + return { + leave: () => { + if (prevTarget !== nextTarget) { + dispatchUIEvent(config, prevTarget, 'mouseout', init) + leave.forEach(el => dispatchUIEvent(config, el, 'mouseleave', init)) + } + }, + enter: () => { + if (prevTarget !== nextTarget) { + dispatchUIEvent(config, nextTarget, 'mouseover', init) + enter.forEach(el => dispatchUIEvent(config, el, 'mouseenter', init)) + } + }, + move: () => { + dispatchUIEvent(config, nextTarget, 'mousemove', init) + + this.modifySelecting(config) + }, + } + } + + down(config: Config, keyDef: pointerKey, pointer: Pointer) { + const button = this.buttons.down(keyDef) + + if (button === undefined) { + return + } + + const target = this.getTarget(config) + 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 && getMouseEventButton(keyDef.button) === 2) { + dispatchUIEvent( + config, + target, + 'contextmenu', + this.getEventInit('contextmenu', keyDef.button, pointer), + ) + } + } + + up(config: Config, keyDef: pointerKey, pointer: Pointer) { + const button = this.buttons.up(keyDef) + + if (button === undefined) { + return + } + const target = this.getTarget(config) + if (!isDisabled(target)) { + dispatchUIEvent( + config, + target, + 'mouseup', + this.getEventInit('mouseup', keyDef.button), + ) + this.endSelecting() + + const clickTarget = getTreeDiff( + this.buttonDownTarget[button], + target, + )[2][0] as Element | undefined + if (clickTarget) { + const init = this.getEventInit('click', keyDef.button, pointer) + if (init.detail) { + dispatchUIEvent( + config, + clickTarget, + init.button === 0 ? 'click' : 'auxclick', + init, + ) + if (init.button === 0 && init.detail === 2) { + dispatchUIEvent(config, clickTarget, 'dblclick', { + ...this.getEventInit('dblclick', keyDef.button), + detail: init.detail, + }) + } + } + } + } + } + + resetClickCount() { + this.clickCount.reset() + } + + private getEventInit( + type: EventType, + button?: MouseButton, + pointer?: Pointer, + ) { + const init: PointerEventInit = { + ...this.position.coords, + } + + if (pointer) { + init.pointerId = pointer.pointerId + init.pointerType = pointer.pointerType + init.isPrimary = pointer.isPrimary + } + + init.button = getMouseEventButton(button) + init.buttons = this.buttons.getButtons() + + if (type === 'mousedown') { + init.detail = this.clickCount.getOnDown(init.button) + } else if (type === 'mouseup') { + init.detail = this.clickCount.getOnUp(init.button) + } else if (type === 'click' || type === 'auxclick') { + init.detail = this.clickCount.incOnClick(init.button) + } + + return init + } + + private getTarget(config: Config) { + return this.position.target ?? config.document.body + } + + private startSelecting(config: Config, clickCount: number) { + // TODO: support extending range (shift) + + this.selecting = setSelectionPerMouseDown({ + document: config.document, + target: this.getTarget(config), + node: this.position.caret?.node, + offset: this.position.caret?.offset, + clickCount, + }) + } + + private modifySelecting(config: Config) { + if (!this.selecting) { + return + } + modifySelectionPerMouseMove(this.selecting, { + document: config.document, + target: this.getTarget(config), + node: this.position.caret?.node, + offset: this.position.caret?.offset, + }) + } + + private endSelecting() { + this.selecting = undefined + } +} diff --git a/src/system/pointer/pointer.ts b/src/system/pointer/pointer.ts new file mode 100644 index 00000000..d7e50328 --- /dev/null +++ b/src/system/pointer/pointer.ts @@ -0,0 +1,143 @@ +import {dispatchUIEvent} from '../../event' +import {Config} from '../../setup' +import {assertPointerEvents, getTreeDiff, hasPointerEvents} from '../../utils' +import {isDifferentPointerPosition, pointerKey, PointerPosition} from '.' + +type PointerInit = { + pointerId: number + pointerType: string + isPrimary: boolean +} + +export class Pointer { + constructor({pointerId, pointerType, isPrimary}: PointerInit) { + this.pointerId = pointerId + this.pointerType = pointerType + this.isPrimary = isPrimary + this.isMultitouch = !isPrimary + } + readonly pointerId: number + readonly pointerType: string + readonly isPrimary: boolean + + isMultitouch: boolean = false + isCancelled: boolean = false + isDown: boolean = false + isPrevented: boolean = false + + position: PointerPosition = {} + + init(config: Config, position: PointerPosition) { + this.position = position + + const target = this.getTarget(config) + const [, enter] = getTreeDiff(null, target) + const init = this.getEventInit() + + assertPointerEvents(config, target) + + dispatchUIEvent(config, target, 'pointerover', init) + enter.forEach(el => dispatchUIEvent(config, el, 'pointerenter', init)) + + return this + } + + move(config: Config, position: PointerPosition) { + const prevPosition = this.position + const prevTarget = this.getTarget(config) + + this.position = position + + if (!isDifferentPointerPosition(prevPosition, position)) { + return + } + + const nextTarget = this.getTarget(config) + + const init = this.getEventInit() + + const [leave, enter] = getTreeDiff(prevTarget, nextTarget) + + return { + leave: () => { + if (hasPointerEvents(config, prevTarget)) { + if (prevTarget !== nextTarget) { + dispatchUIEvent(config, prevTarget, 'pointerout', init) + leave.forEach(el => + dispatchUIEvent(config, el, 'pointerleave', init), + ) + } + } + }, + enter: () => { + assertPointerEvents(config, nextTarget) + + if (prevTarget !== nextTarget) { + dispatchUIEvent(config, nextTarget, 'pointerover', init) + enter.forEach(el => dispatchUIEvent(config, el, 'pointerenter', init)) + } + }, + move: () => { + dispatchUIEvent(config, nextTarget, 'pointermove', init) + }, + } + } + + down(config: Config, _keyDef: pointerKey) { + if (this.isDown) { + return + } + const target = this.getTarget(config) + + assertPointerEvents(config, target) + + this.isDown = true + this.isPrevented = !dispatchUIEvent( + config, + target, + 'pointerdown', + this.getEventInit(), + ) + } + + up(config: Config, _keyDef: pointerKey) { + if (!this.isDown) { + return + } + const target = this.getTarget(config) + + assertPointerEvents(config, target) + + this.isDown = false + dispatchUIEvent(config, target, 'pointerup', this.getEventInit()) + } + + release(config: Config) { + const target = this.getTarget(config) + 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)) + } + + this.isCancelled = true + } + + private getTarget(config: Config) { + return this.position.target ?? config.document.body + } + + private getEventInit(): PointerEventInit { + return { + ...this.position.coords, + pointerId: this.pointerId, + pointerType: this.pointerType, + isPrimary: this.isPrimary, + } + } +} diff --git a/src/utils/click/isClickableInput.ts b/src/utils/click/isClickableInput.ts index c29eedeb..2309be3e 100644 --- a/src/utils/click/isClickableInput.ts +++ b/src/utils/click/isClickableInput.ts @@ -1,20 +1,24 @@ import {isElementType} from '../misc/isElementType' -const CLICKABLE_INPUT_TYPES = [ - 'button', - 'color', - 'file', - 'image', - 'reset', - 'submit', - 'checkbox', - 'radio', -] +enum clickableInputTypes { + 'button' = 'button', + 'color' = 'color', + 'file' = 'file', + 'image' = 'image', + 'reset' = 'reset', + 'submit' = 'submit', + 'checkbox' = 'checkbox', + 'radio' = 'radio', +} +export type ClickableInputType = keyof typeof clickableInputTypes -export function isClickableInput(element: Element): boolean { +export function isClickableInput( + element: Element, +): element is + | HTMLButtonElement + | (HTMLInputElement & {type: clickableInputTypes}) { return ( isElementType(element, 'button') || - (isElementType(element, 'input') && - CLICKABLE_INPUT_TYPES.includes(element.type)) + (isElementType(element, 'input') && element.type in clickableInputTypes) ) } diff --git a/src/utils/focus/getActiveElement.ts b/src/utils/focus/getActiveElement.ts index b2bd25c5..725c2524 100644 --- a/src/utils/focus/getActiveElement.ts +++ b/src/utils/focus/getActiveElement.ts @@ -19,3 +19,7 @@ export function getActiveElement( return activeElement } } + +export function getActiveElementOrBody(document: Document): Element { + return getActiveElement(document) ?? /* istanbul ignore next */ document.body +} diff --git a/src/pointer/resolveSelectionTarget.ts b/src/utils/focus/resolveCaretPosition.ts similarity index 85% rename from src/pointer/resolveSelectionTarget.ts rename to src/utils/focus/resolveCaretPosition.ts index a970b5da..3299209b 100644 --- a/src/pointer/resolveSelectionTarget.ts +++ b/src/utils/focus/resolveCaretPosition.ts @@ -1,13 +1,16 @@ -import {getUIValue} from '../document' -import {isElementType} from '../utils' -import {PointerTarget, SelectionTarget} from './types' +import {getUIValue} from '../../document' +import {hasOwnSelection} from '..' -export function resolveSelectionTarget({ +export function resolveCaretPosition({ target, node, offset, -}: PointerTarget & SelectionTarget) { - if (isElementType(target, ['input', 'textarea'])) { +}: { + target: Element + node?: Node + offset?: number +}) { + if (hasOwnSelection(target)) { return { node: target, offset: offset ?? getUIValue(target).length, @@ -49,6 +52,9 @@ function findNodeAtTextOffset( ? i >= (isRoot ? Math.max(node.childNodes.length - 1, 0) : 0) : i <= node.childNodes.length ) { + if (offset && i === node.childNodes.length) { + throw new Error('The given offset is out of bounds.') + } const c = node.childNodes.item(i) const text = String(c.textContent) diff --git a/src/utils/focus/selection.ts b/src/utils/focus/selection.ts index 2149309d..7453fbf2 100644 --- a/src/utils/focus/selection.ts +++ b/src/utils/focus/selection.ts @@ -1,8 +1,15 @@ import {isElementType} from '../misc/isElementType' -import {getUISelection, setUISelection, UISelectionRange} from '../../document' -import {editableInputTypes} from '../edit/isEditable' +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. @@ -53,6 +60,10 @@ export function hasOwnSelection( ) } +export function hasNoSelection(node: Node) { + return isElement(node) && isClickableInput(node) +} + function isElement(node: Node): node is Element { return node.nodeType === 1 } @@ -238,3 +249,167 @@ export function moveSelection(node: Element, direction: -1 | 1) { } } } + +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 72eebad1..593c3129 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -20,18 +20,17 @@ 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 './keyboard/getKeyEventProps' -export * from './keyboard/getUIEventModifiers' - export * from './keyDef/readNextDescriptor' export * from './misc/cloneEvent' export * from './misc/eventWrapper' export * from './misc/findClosest' export * from './misc/getDocumentFromNode' +export * from './misc/getTreeDiff' export * from './misc/getWindow' export * from './misc/isDescendantOrSelf' export * from './misc/isElementType' @@ -41,4 +40,3 @@ export * from './misc/level' export * from './misc/wait' export * from './pointer/cssPointerEvents' -export * from './pointer/mouseButtons' diff --git a/src/utils/keyboard/getKeyEventProps.ts b/src/utils/keyboard/getKeyEventProps.ts deleted file mode 100644 index 7276c98a..00000000 --- a/src/utils/keyboard/getKeyEventProps.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {keyboardKey} from '../../keyboard/types' - -export function getKeyEventProps(keyDef: keyboardKey) { - return { - key: keyDef.key, - code: keyDef.code, - } -} diff --git a/src/utils/keyboard/getUIEventModifiers.ts b/src/utils/keyboard/getUIEventModifiers.ts deleted file mode 100644 index ccd6d160..00000000 --- a/src/utils/keyboard/getUIEventModifiers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type {keyboardState} from '../../keyboard' - -export function getUIEventModifiers(keyboardState: keyboardState) { - return { - altKey: keyboardState.modifiers.Alt, - ctrlKey: keyboardState.modifiers.Control, - metaKey: keyboardState.modifiers.Meta, - shiftKey: keyboardState.modifiers.Shift, - modifierAltGraph: keyboardState.modifiers.AltGraph, - modifierCapsLock: keyboardState.modifiers.CapsLock, - modifierFn: keyboardState.modifiers.Fn, - modifierFnLock: keyboardState.modifiers.FnLock, - modifierNumLock: keyboardState.modifiers.NumLock, - modifierScrollLock: keyboardState.modifiers.ScrollLock, - modifierSymbol: keyboardState.modifiers.Symbol, - modifierSymbolLock: keyboardState.modifiers.SymbolLock, - } -} diff --git a/src/utils/misc/getTreeDiff.ts b/src/utils/misc/getTreeDiff.ts new file mode 100644 index 00000000..2b296dfe --- /dev/null +++ b/src/utils/misc/getTreeDiff.ts @@ -0,0 +1,25 @@ +export function getTreeDiff(a: Element | null, b: Element | null) { + const treeA = [] + for (let el = a; el; el = el.parentElement) { + treeA.push(el) + } + const treeB = [] + for (let el = b; el; el = el.parentElement) { + treeB.push(el) + } + let i = 0 + for (; ; i++) { + if ( + i >= treeA.length || + i >= treeB.length || + treeA[treeA.length - 1 - i] !== treeB[treeB.length - 1 - i] + ) { + break + } + } + return [ + treeA.slice(0, treeA.length - i), + treeB.slice(0, treeB.length - i), + treeB.slice(treeB.length - i), + ] as const +} diff --git a/src/utils/pointer/mouseButtons.ts b/src/utils/pointer/mouseButtons.ts deleted file mode 100644 index 47d3ecec..00000000 --- a/src/utils/pointer/mouseButtons.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const MouseButton = { - primary: 0, - secondary: 1, - auxiliary: 2, - back: 3, - X1: 3, - forward: 4, - X2: 4, -} as const - -export type MouseButton = keyof typeof MouseButton | number - -// Some legacy... -const MouseButtonFlip = { - auxiliary: 1, - secondary: 2, - 1: 2, - 2: 1, -} as const - -export function getMouseButton(button: MouseButton): number { - if (button in MouseButtonFlip) { - return MouseButtonFlip[button as keyof typeof MouseButtonFlip] - } - return typeof button === 'number' ? button : MouseButton[button] -} - -export function getMouseButtons(...buttons: Array) { - let v = 0 - for (const t of buttons) { - const pos = typeof t === 'number' ? t : MouseButton[t] - // eslint-disable-next-line no-bitwise - v |= 2 ** pos - } - return v -} diff --git a/tests/_helpers/listeners.ts b/tests/_helpers/listeners.ts index 2b8cf15c..334f34c6 100644 --- a/tests/_helpers/listeners.ts +++ b/tests/_helpers/listeners.ts @@ -1,6 +1,7 @@ import {TestData, TestDataProps} from './trackProps' import {eventMapKeys} from '#src/event/eventMap' -import {isElementType, MouseButton} from '#src/utils' +import {isElementType} from '#src/utils' +import {MouseButton, MouseButtonFlip} from '#src/system/pointer/buttons' let eventListeners: Array<{ el: EventTarget @@ -229,6 +230,7 @@ function getEventLabel(event: Event) { isMouseEvent(event) && [ 'click', + 'auxclick', 'dblclick', 'mousedown', 'mouseup', @@ -250,6 +252,9 @@ function getEventModifiers(event: Event) { } function getMouseButtonName(button: number) { + if (button in MouseButtonFlip) { + button = MouseButtonFlip[button as keyof typeof MouseButtonFlip] + } return Object.keys(MouseButton).find( k => MouseButton[k as keyof typeof MouseButton] === button, ) diff --git a/tests/convenience/hover.ts b/tests/convenience/hover.ts index 7c884d44..fedd7b16 100644 --- a/tests/convenience/hover.ts +++ b/tests/convenience/hover.ts @@ -2,8 +2,8 @@ import {PointerEventsCheckLevel} from '#src' import {setup} from '#testHelpers' describe.each([ - ['hover', {events: ['over', 'enter', 'move']}], - ['unhover', {events: ['move', 'leave', 'out']}], + ['hover', {events: ['over', 'enter']}], + ['unhover', {events: ['leave', 'out']}], ] as const)('%s', (method, {events}) => { test(`${method} element`, async () => { const {element, getEvents, clearEventCalls, user} = setup('
') @@ -16,9 +16,6 @@ describe.each([ expect(getEvents(`pointer${type}`)).toHaveLength(1) expect(getEvents(`mouse${type}`)).toHaveLength(1) } - - expect(getEvents('pointermove')).toHaveLength(1) - expect(getEvents('mousemove')).toHaveLength(1) }) test('throw on pointer-events set to none', async () => { @@ -49,6 +46,6 @@ describe.each([ await user[method](element) - expect(getEvents('mousemove')).toHaveLength(1) + events.forEach(t => expect(getEvents(`mouse${t}`)).toHaveLength(1)) }) }) diff --git a/tests/event/behavior/keydown.ts b/tests/event/behavior/keydown.ts index 8cd2aae6..142a5f2b 100644 --- a/tests/event/behavior/keydown.ts +++ b/tests/event/behavior/keydown.ts @@ -214,7 +214,7 @@ test('select input per `Control+A`', async () => { }) const config = createConfig() - config.keyboardState.modifiers.Control = true + config.system.keyboard.modifiers.Control = true dispatchUIEvent(config, element, 'keydown', {code: 'KeyA'}) @@ -259,7 +259,7 @@ cases( ) const config = createConfig() - config.keyboardState.modifiers.Shift = shiftKey + config.system.keyboard.modifiers.Shift = shiftKey dispatchUIEvent(config, document.activeElement as Element, 'keydown', { key: 'Tab', diff --git a/tests/event/behavior/keypress.ts b/tests/event/behavior/keypress.ts index c40d1979..bdc22170 100644 --- a/tests/event/behavior/keypress.ts +++ b/tests/event/behavior/keypress.ts @@ -32,7 +32,7 @@ cases( const config = createConfig() if (shiftKey) { - config.keyboardState.modifiers.Shift = true + config.system.keyboard.modifiers.Shift = true } dispatchUIEvent(config, element, 'keypress', { diff --git a/tests/keyboard/modifiers.ts b/tests/keyboard/modifiers.ts index 73686afc..600566f1 100644 --- a/tests/keyboard/modifiers.ts +++ b/tests/keyboard/modifiers.ts @@ -12,6 +12,8 @@ test.each([ const modifierDown = getEvents('keydown')[0] expect(modifierDown).toHaveProperty('key', key) expect(modifierDown).toHaveProperty(modifier, true) + // This should be true, but this is a bug in JSDOM + // expect(modifierDown.getModifierState(key)).toBe(true) await user.keyboard('a') expect(getEvents('keydown')[1]).toHaveProperty(modifier, true) @@ -21,6 +23,7 @@ test.each([ const modifierUp = getEvents('keyup')[1] expect(modifierUp).toHaveProperty('key', key) expect(modifierUp).toHaveProperty(modifier, false) + expect(modifierUp.getModifierState(key)).toBe(false) }) test.each([['AltGraph'], ['Fn'], ['Symbol']])( @@ -29,14 +32,12 @@ test.each([['AltGraph'], ['Fn'], ['Symbol']])( const {getEvents, user} = setup(`
`) await user.keyboard(`{${key}>}`) - const modifierDown = getEvents('keydown')[key === 'AltGraph' ? 1 : 0] + const modifierDown = getEvents('keydown')[0] expect(modifierDown).toHaveProperty('key', key) expect(modifierDown.getModifierState(key)).toBe(true) await user.keyboard('a') - expect( - getEvents('keydown')[key === 'AltGraph' ? 2 : 1].getModifierState(key), - ).toBe(true) + expect(getEvents('keydown')[1].getModifierState(key)).toBe(true) await user.keyboard(`{/${key}}`) const modifierUp = getEvents('keyup')[1] @@ -55,27 +56,15 @@ test.each([ await user.keyboard(`{${key}}`) const modifierOn = getEvents('keydown')[0] - expect(modifierOn.getModifierState(key)).toBe(true) + expect(modifierOn.getModifierState(key)).toBe(false) await user.keyboard(`a`) expect(getEvents('keydown')[1].getModifierState(key)).toBe(true) await user.keyboard(`{${key}}`) const modifierOff = getEvents('keyup')[2] - expect(modifierOff.getModifierState(key)).toBe(false) -}) - -test('produce extra events for the Control key when AltGraph is pressed', async () => { - const {getEventSnapshot, user} = setup(``) - - await user.keyboard('{AltGraph}') + expect(modifierOff.getModifierState(key)).toBe(true) - expect(getEventSnapshot()).toMatchInlineSnapshot(` - Events fired on: input[value=""] - - input[value=""] - keydown: Control - input[value=""] - keydown: AltGraph - input[value=""] - keyup: AltGraph - input[value=""] - keyup: Control - `) + await user.keyboard(`a`) + expect(getEvents('keydown')[3].getModifierState(key)).toBe(false) }) diff --git a/tests/keyboard/parseKeyDef.ts b/tests/keyboard/parseKeyDef.ts index 977c374a..1dd6b7d8 100644 --- a/tests/keyboard/parseKeyDef.ts +++ b/tests/keyboard/parseKeyDef.ts @@ -1,7 +1,7 @@ import cases from 'jest-in-case' import {parseKeyDef} from '#src/keyboard/parseKeyDef' import {defaultKeyMap} from '#src/keyboard/keyMap' -import {keyboardKey} from '#src/keyboard/types' +import {keyboardKey} from '#src' cases( 'reference key per', diff --git a/tests/pointer/click.ts b/tests/pointer/click.ts index 6d4bebc3..88dc8452 100644 --- a/tests/pointer/click.ts +++ b/tests/pointer/click.ts @@ -21,7 +21,7 @@ test('secondary button triggers contextmenu', async () => { expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` pointerdown - pointerId=1; pointerType=mouse; isPrimary=true mousedown - button=2; buttons=2; detail=1 - contextmenu - button=2; buttons=2; detail=1 + contextmenu - button=2; buttons=2; detail=0 `) expect(getEvents('contextmenu')).toHaveLength(1) }) @@ -83,7 +83,7 @@ test('two clicks', async () => { expect(getEvents('mousedown')[1]).toHaveProperty('detail', 1) }) -test('other keys reset click counter, but keyup/click still uses the old count', async () => { +test('other keys reset click counter', async () => { const {element, getClickEventsSnapshot, getEvents, user} = setup(`
`) @@ -101,12 +101,11 @@ test('other keys reset click counter, but keyup/click still uses the old count', pointerdown - pointerId=1; pointerType=mouse; isPrimary=true mousedown - button=0; buttons=1; detail=2 mousedown - button=2; buttons=3; detail=1 - contextmenu - button=2; buttons=3; detail=1 + contextmenu - button=2; buttons=3; detail=0 mouseup - button=2; buttons=1; detail=1 + auxclick - button=2; buttons=1; detail=1 pointerup - pointerId=1; pointerType=mouse; isPrimary=true - mouseup - button=0; buttons=0; detail=2 - click - button=0; buttons=0; detail=2 - dblclick - button=0; buttons=0; detail=2 + mouseup - button=0; buttons=0; detail=0 pointerdown - pointerId=1; pointerType=mouse; isPrimary=true mousedown - button=0; buttons=1; detail=1 pointerup - pointerId=1; pointerType=mouse; isPrimary=true @@ -114,7 +113,7 @@ test('other keys reset click counter, but keyup/click still uses the old count', click - button=0; buttons=0; detail=1 `) - expect(getEvents('mouseup')[2]).toHaveProperty('detail', 2) + expect(getEvents('mouseup')[2]).toHaveProperty('detail', 0) expect(getEvents('mousedown')[3]).toHaveProperty('detail', 1) }) @@ -125,16 +124,16 @@ test('click per touch device', async () => { await user.pointer({keys: '[TouchA]', target: element}) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerId=2; pointerType=touch; isPrimary=undefined - pointerenter - pointerId=2; pointerType=touch; isPrimary=undefined + pointerover - pointerId=2; pointerType=touch; isPrimary=true + pointerenter - pointerId=2; pointerType=touch; isPrimary=true pointerdown - pointerId=2; pointerType=touch; isPrimary=true pointerup - pointerId=2; pointerType=touch; isPrimary=true - pointerout - pointerId=2; pointerType=touch; isPrimary=undefined - pointerleave - pointerId=2; pointerType=touch; isPrimary=undefined + pointerout - pointerId=2; pointerType=touch; isPrimary=true + pointerleave - pointerId=2; pointerType=touch; isPrimary=true mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 mousemove - button=0; buttons=0; detail=0 - mousedown - button=0; buttons=0; detail=1 + mousedown - button=0; buttons=1; detail=1 mouseup - button=0; buttons=0; detail=1 click - button=0; buttons=0; detail=1 `) @@ -151,26 +150,25 @@ test('double click per touch device', async () => { await user.pointer({keys: '[TouchA][TouchA]', target: element}) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerId=2; pointerType=touch; isPrimary=undefined - pointerenter - pointerId=2; pointerType=touch; isPrimary=undefined + pointerover - pointerId=2; pointerType=touch; isPrimary=true + pointerenter - pointerId=2; pointerType=touch; isPrimary=true pointerdown - pointerId=2; pointerType=touch; isPrimary=true pointerup - pointerId=2; pointerType=touch; isPrimary=true - pointerout - pointerId=2; pointerType=touch; isPrimary=undefined - pointerleave - pointerId=2; pointerType=touch; isPrimary=undefined + pointerout - pointerId=2; pointerType=touch; isPrimary=true + pointerleave - pointerId=2; pointerType=touch; isPrimary=true mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 mousemove - button=0; buttons=0; detail=0 - mousedown - button=0; buttons=0; detail=1 + mousedown - button=0; buttons=1; detail=1 mouseup - button=0; buttons=0; detail=1 click - button=0; buttons=0; detail=1 - pointerover - pointerId=3; pointerType=touch; isPrimary=undefined - pointerenter - pointerId=3; pointerType=touch; isPrimary=undefined + pointerover - pointerId=3; pointerType=touch; isPrimary=true + pointerenter - pointerId=3; pointerType=touch; isPrimary=true pointerdown - pointerId=3; pointerType=touch; isPrimary=true pointerup - pointerId=3; pointerType=touch; isPrimary=true - pointerout - pointerId=3; pointerType=touch; isPrimary=undefined - pointerleave - pointerId=3; pointerType=touch; isPrimary=undefined - mousemove - button=0; buttons=0; detail=0 - mousedown - button=0; buttons=0; detail=2 + pointerout - pointerId=3; pointerType=touch; isPrimary=true + pointerleave - pointerId=3; pointerType=touch; isPrimary=true + mousedown - button=0; buttons=1; detail=2 mouseup - button=0; buttons=0; detail=2 click - button=0; buttons=0; detail=2 dblclick - button=0; buttons=0; detail=2 @@ -289,16 +287,56 @@ describe('submit form per click', () => { }) }) -test('secondary mouse button fires `contextmenu` instead of `click`', async () => { - const {element, getEvents, clearEventCalls, user} = setup(``) + const config = createConfig() + + await config.system.pointer.press( + config, + {name: 'a', pointerType: 'mouse'}, + {target: element}, + ) + await config.system.pointer.press( + config, + {name: 'b', pointerType: 'mouse'}, + {target: element}, + ) + await config.system.pointer.press( + config, + {name: 'b', pointerType: 'mouse'}, + {target: element}, + ) + expect(getEvents('pointerdown')).toHaveLength(1) + expect(getEvents('mousedown')).toHaveLength(1) + + await config.system.pointer.release( + config, + {name: 'a', pointerType: 'mouse'}, + {target: element}, + ) + await config.system.pointer.release( + config, + {name: 'b', pointerType: 'mouse'}, + {target: element}, + ) + await config.system.pointer.release( + config, + {name: 'b', pointerType: 'mouse'}, + {target: element}, + ) + expect(getEvents('pointerup')).toHaveLength(1) + expect(getEvents('mouseup')).toHaveLength(1) +}) diff --git a/tests/utility/selectOptions/select.ts b/tests/utility/selectOptions/select.ts index 097f4827..ee1900a7 100644 --- a/tests/utility/selectOptions/select.ts +++ b/tests/utility/selectOptions/select.ts @@ -44,7 +44,9 @@ test('fires correct events on listBox select', async () => { Events fired on: ul[value="2"] li#2[value="2"][aria-selected=false] - pointerover + ul - pointerenter li#2[value="2"][aria-selected=false] - mouseover + ul - mouseenter li#2[value="2"][aria-selected=false] - pointermove li#2[value="2"][aria-selected=false] - mousemove li#2[value="2"][aria-selected=false] - pointerdown @@ -52,10 +54,10 @@ test('fires correct events on listBox select', async () => { li#2[value="2"][aria-selected=false] - pointerup li#2[value="2"][aria-selected=false] - mouseup: primary li#2[value="2"][aria-selected=true] - click: primary - li#2[value="2"][aria-selected=true] - pointermove - li#2[value="2"][aria-selected=true] - mousemove li#2[value="2"][aria-selected=true] - pointerout + ul[value="2"] - pointerleave li#2[value="2"][aria-selected=true] - mouseout + ul[value="2"] - mouseleave `) const [o1, o2, o3] = options expect(o1).toHaveAttribute('aria-selected', 'false') diff --git a/tests/utility/upload.ts b/tests/utility/upload.ts index 2905aa4d..ad887347 100644 --- a/tests/utility/upload.ts +++ b/tests/utility/upload.ts @@ -59,7 +59,9 @@ test('relay click/upload on label to file input', async () => { Events fired on: div label[for="element"] - pointerover + div - pointerenter label[for="element"] - mouseover + div - mouseenter label[for="element"] - pointermove label[for="element"] - mousemove label[for="element"] - pointerdown