From 4fc998907522e4399ac88f7f9eb71ea0f6324932 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 22 Oct 2021 16:14:40 +0000 Subject: [PATCH 1/5] feat!: change input selection on `mousedown` BREAKING CHANGE: `userEvent.type` does no longer move the cursor if used with `skipClick=false` and without `initialSelectionStart`. --- src/pointer/pointerPress.ts | 58 ++++++++++++++++++++++++--- src/type.ts | 34 +++------------- src/utils/edit/getValue.ts | 4 +- tests/clear.js | 4 ++ tests/keyboard/plugin/arrow.ts | 47 +++++++++++----------- tests/pointer/index.ts | 43 ++++++++++++++++++++ tests/type/index.js | 30 ++++++++------ tests/type/modifiers.js | 22 +++++++--- tests/utils/edit/calculateNewValue.ts | 4 +- 9 files changed, 168 insertions(+), 78 deletions(-) diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index 24aa6e16..92472284 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -7,8 +7,10 @@ import { firePointerEvent, focus, isDisabled, + isElementType, isFocusable, } from '../utils' +import {getUIValue, setUISelection} from '../document' import type { inputDeviceState, pointerKey, @@ -128,7 +130,7 @@ function down( } if (pointerType === 'mouse' && pressObj.unpreventedDefault) { - focus(findClosest(target, isFocusable) ?? target.ownerDocument.body) + mousedownDefaultBehavior({target, targetIsDisabled, clickCount}) } return pressObj @@ -193,10 +195,7 @@ function up( } if (unpreventedDefault && pointerType !== 'mouse' && !isMultiTouch) { - // The closest focusable element is focused when a `mousedown` would have been fired. - // Even if there was no `mousedown` because the element was disabled. - // A `mousedown` that preventsDefault cancels this though. - focus(findClosest(target, isFocusable) ?? target.ownerDocument.body) + mousedownDefaultBehavior({target, targetIsDisabled, clickCount}) } if (!targetIsDisabled) { @@ -239,3 +238,52 @@ function up( }) } } + +function mousedownDefaultBehavior({ + target, + targetIsDisabled, + clickCount, +}: { + target: Element + targetIsDisabled: boolean + clickCount: number +}) { + // The closest focusable element is focused when a `mousedown` would have been fired. + // Even if there was no `mousedown` because the element was disabled. + // A `mousedown` that preventsDefault cancels this though. + focus(findClosest(target, isFocusable) ?? target.ownerDocument.body) + + // TODO: What happens if a focus event handler interfers? + + // An unprevented mousedown moves the cursor to the closest character. + // We try to approximate the behavior for a no-layout environment. + // TODO: implement for other elements + if (!targetIsDisabled && isElementType(target, ['input', 'textarea'])) { + const value = getUIValue(target) as string + const pos = value.length // might override this per option later + + if (clickCount % 3 === 1 || value.length === 0) { + setUISelection(target, pos, pos) + } else if (clickCount % 3 === 2) { + const isWhitespace = /\s/.test(value.substr(Math.max(0, pos - 1), 1)) + const before = ( + value + .substr(0, pos) + .match(isWhitespace ? /\s*$/ : /\S*$/) as RegExpMatchArray + )[0].length + const after = ( + value + .substr(pos) + .match(isWhitespace ? /^\s*/ : /^\S*/) as RegExpMatchArray + )[0].length + setUISelection(target, pos - before, pos + after) + } else { + const before = ( + value.substr(0, pos).match(/[^\r\n]*$/) as RegExpMatchArray + )[0].length + const after = (value.substr(pos).match(/^[\r\n]*/) as RegExpMatchArray)[0] + .length + setUISelection(target, pos - before, pos + after) + } + } +} diff --git a/src/type.ts b/src/type.ts index 4cae128c..26efb0c0 100644 --- a/src/type.ts +++ b/src/type.ts @@ -1,12 +1,7 @@ import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' import {prepareDocument} from './document' import type {UserEvent} from './setup' -import { - setSelectionRange, - getSelectionRange, - getValue, - getActiveElement, -} from './utils' +import {setSelectionRange} from './utils' import {keyboardImplementationWrapper} from './keyboard' export interface typeOptions { @@ -72,30 +67,11 @@ async function typeImplementation( if (!skipClick) userEvent.click(element) - // The focused element could change between each event, so get the currently active element each time - const currentElement = () => getActiveElement(element.ownerDocument) - - // by default, a new element has its selection start and end at 0 - // but most of the time when people call "type", they expect it to type - // at the end of the current input value. So, if the selection start - // and end are both the default of 0, then we'll go ahead and change - // them to the length of the current value. - // the only time it would make sense to pass the initialSelectionStart or - // initialSelectionEnd is if you have an input with a value and want to - // explicitly start typing with the cursor at 0. Not super common. - const value = getValue(currentElement()) - - const {selectionStart, selectionEnd} = getSelectionRange(element) - - if ( - value != null && - (selectionStart === null || selectionStart === 0) && - (selectionEnd === null || selectionEnd === 0) - ) { + if (initialSelectionStart !== undefined) { setSelectionRange( - currentElement() as Element, - initialSelectionStart ?? value.length, - initialSelectionEnd ?? value.length, + element, + initialSelectionStart, + initialSelectionEnd ?? initialSelectionStart, ) } diff --git a/src/utils/edit/getValue.ts b/src/utils/edit/getValue.ts index 1f010332..35cb09fe 100644 --- a/src/utils/edit/getValue.ts +++ b/src/utils/edit/getValue.ts @@ -4,7 +4,7 @@ import {isContentEditable} from './isContentEditable' export function getValue( element: T, ): T extends HTMLInputElement | HTMLTextAreaElement ? string : string | null -export function getValue(element: Element | null): string | null { +export function getValue(element: Element | null): string | null | undefined { // istanbul ignore if if (!element) { return null @@ -12,5 +12,5 @@ export function getValue(element: Element | null): string | null { if (isContentEditable(element)) { return element.textContent } - return getUIValue(element as HTMLInputElement) ?? null + return getUIValue(element as HTMLInputElement) } diff --git a/tests/clear.js b/tests/clear.js index 8717b81b..79d39c20 100644 --- a/tests/clear.js +++ b/tests/clear.js @@ -18,10 +18,12 @@ test('clears text', () => { input[value="hello"] - mousedown input[value="hello"] - focus input[value="hello"] - focusin + input[value="hello"] - select input[value="hello"] - pointerup input[value="hello"] - mouseup input[value="hello"] - click input[value="hello"] - select + input[value="hello"] - select input[value="hello"] - keydown: Delete (46) input[value=""] - input input[value=""] - keyup: Delete (46) @@ -60,10 +62,12 @@ test('does not clear text on readonly inputs', () => { input[value="hello"] - mousedown input[value="hello"] - focus input[value="hello"] - focusin + input[value="hello"] - select input[value="hello"] - pointerup input[value="hello"] - mouseup input[value="hello"] - click input[value="hello"] - select + input[value="hello"] - select input[value="hello"] - keydown: Delete (46) input[value="hello"] - keyup: Delete (46) `) diff --git a/tests/keyboard/plugin/arrow.ts b/tests/keyboard/plugin/arrow.ts index d988af02..f34e5eb0 100644 --- a/tests/keyboard/plugin/arrow.ts +++ b/tests/keyboard/plugin/arrow.ts @@ -1,45 +1,46 @@ import userEvent from '#src' import {setup} from '#testHelpers/utils' -const setupInput = () => - setup(``).element - test('collapse selection to the left', () => { - const el = setupInput() - el.setSelectionRange(2, 4) + const {element} = setup(``) + element.focus() + element.setSelectionRange(2, 4) - userEvent.type(el, '[ArrowLeft]') + userEvent.keyboard('[ArrowLeft]') - expect(el.selectionStart).toBe(2) - expect(el.selectionEnd).toBe(2) + expect(element.selectionStart).toBe(2) + expect(element.selectionEnd).toBe(2) }) test('collapse selection to the right', () => { - const el = setupInput() - el.setSelectionRange(2, 4) + const {element} = setup(``) + element.focus() + element.setSelectionRange(2, 4) - userEvent.type(el, '[ArrowRight]') + userEvent.keyboard('[ArrowRight]') - expect(el.selectionStart).toBe(4) - expect(el.selectionEnd).toBe(4) + expect(element.selectionStart).toBe(4) + expect(element.selectionEnd).toBe(4) }) test('move cursor left', () => { - const el = setupInput() - el.setSelectionRange(2, 2) + const {element} = setup(``) + element.focus() + element.setSelectionRange(2, 2) - userEvent.type(el, '[ArrowLeft]') + userEvent.keyboard('[ArrowLeft]') - expect(el.selectionStart).toBe(1) - expect(el.selectionEnd).toBe(1) + expect(element.selectionStart).toBe(1) + expect(element.selectionEnd).toBe(1) }) test('move cursor right', () => { - const el = setupInput() - el.setSelectionRange(2, 2) + const {element} = setup(``) + element.focus() + element.setSelectionRange(2, 2) - userEvent.type(el, '[ArrowRight]') + userEvent.keyboard('[ArrowRight]') - expect(el.selectionStart).toBe(3) - expect(el.selectionEnd).toBe(3) + expect(element.selectionStart).toBe(3) + expect(element.selectionEnd).toBe(3) }) diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index f62e8845..6137c684 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -416,3 +416,46 @@ test('apply modifiers from keyboardstate', async () => { expect.objectContaining({metaKey: true}), ]) }) + +describe('mousedown moves selection', () => { + // On an unprevented mousedown the browser moves the cursor to the closest character. + // As we have no layout, we are not able to determine the correct character. + // So we try an approximation: + // We treat any mousedown as if it happened on the space after the last character. + + test('single click moves cursor to the end', () => { + const {element} = setup(``) + + userEvent.pointer({keys: '[MouseLeft]', target: element}) + + expect(element).toHaveProperty('selectionStart', 11) + }) + + test('double click selects a word or a sequence of whitespace', () => { + const {element} = setup(``) + + userEvent.pointer({keys: '[MouseLeft][MouseLeft]', target: element}) + + expect(element).toHaveProperty('selectionStart', 8) + expect(element).toHaveProperty('selectionEnd', 11) + + element.value = 'foo bar ' + + userEvent.pointer({keys: '[MouseLeft][MouseLeft]', target: element}) + + expect(element).toHaveProperty('selectionStart', 7) + expect(element).toHaveProperty('selectionEnd', 9) + }) + + test('triple click selects whole line', () => { + const {element} = setup(``) + + userEvent.pointer({ + keys: '[MouseLeft][MouseLeft][MouseLeft]', + target: element, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 11) + }) +}) diff --git a/tests/type/index.js b/tests/type/index.js index e5ff2bc5..5e13e42a 100644 --- a/tests/type/index.js +++ b/tests/type/index.js @@ -270,10 +270,10 @@ test('should fire events on the currently focused element', () => { test('should replace selected text', () => { const {element} = setup('') - const selectionStart = 'hello world'.search('world') - const selectionEnd = selectionStart + 'world'.length - element.setSelectionRange(selectionStart, selectionEnd) - userEvent.type(element, 'friend') + userEvent.type(element, 'friend', { + initialSelectionStart: 6, + initialSelectionEnd: 11, + }) expect(element).toHaveValue('hello friend') }) @@ -340,15 +340,13 @@ test('typing into a controlled input works', () => { test('typing in the middle of a controlled input works', () => { const {element, getEventSnapshot} = setupDollarInput({initialValue: '$23'}) - element.setSelectionRange(2, 2) - userEvent.type(element, '1') + userEvent.type(element, '1', {initialSelectionStart: 2}) expect(element).toHaveValue('$213') expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="$213"] - input[value="$23"] - select input[value="$23"] - pointerover input[value="$23"] - pointerenter input[value="$23"] - mouseover @@ -359,9 +357,11 @@ test('typing in the middle of a controlled input works', () => { input[value="$23"] - mousedown input[value="$23"] - focus input[value="$23"] - focusin + input[value="$23"] - select input[value="$23"] - pointerup input[value="$23"] - mouseup input[value="$23"] - click + input[value="$23"] - select input[value="$23"] - keydown: 1 (49) input[value="$23"] - keypress: 1 (49) input[value="$213"] - select @@ -372,9 +372,11 @@ test('typing in the middle of a controlled input works', () => { test('ignored {backspace} in controlled input', () => { const {element, getEventSnapshot} = setupDollarInput({initialValue: '$23'}) - element.setSelectionRange(1, 1) - userEvent.type(element, '{backspace}') + userEvent.type(element, '{backspace}', { + initialSelectionStart: 1, + initialSelectionEnd: 1, + }) // this is the same behavior in the browser. // in our case, when you try to backspace the "$", our event handler // will ignore that change and React resets the value to what it was @@ -390,7 +392,6 @@ test('ignored {backspace} in controlled input', () => { expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="$234"] - input[value="$23"] - select input[value="$23"] - pointerover input[value="$23"] - pointerenter input[value="$23"] - mouseover @@ -401,9 +402,11 @@ test('ignored {backspace} in controlled input', () => { input[value="$23"] - mousedown input[value="$23"] - focus input[value="$23"] - focusin + input[value="$23"] - select input[value="$23"] - pointerup input[value="$23"] - mouseup input[value="$23"] - click + input[value="$23"] - select input[value="$23"] - keydown: Backspace (8) input[value="23"] - select input[value="23"] - input @@ -453,10 +456,10 @@ test('typing in a textarea with existing text', () => { textarea[value="Hello, "] - mousedown textarea[value="Hello, "] - focus textarea[value="Hello, "] - focusin + textarea[value="Hello, "] - select textarea[value="Hello, "] - pointerup textarea[value="Hello, "] - mouseup textarea[value="Hello, "] - click - textarea[value="Hello, "] - select textarea[value="Hello, "] - keydown: 1 (49) textarea[value="Hello, "] - keypress: 1 (49) textarea[value="Hello, 1"] - input @@ -492,9 +495,11 @@ test('accepts an initialSelectionStart and initialSelectionEnd', () => { textarea[value="Hello, "] - mousedown textarea[value="Hello, "] - focus textarea[value="Hello, "] - focusin + textarea[value="Hello, "] - select textarea[value="Hello, "] - pointerup textarea[value="Hello, "] - mouseup textarea[value="Hello, "] - click + textarea[value="Hello, "] - select textarea[value="Hello, "] - keydown: 1 (49) textarea[value="Hello, "] - keypress: 1 (49) textarea[value="1Hello, "] - select @@ -1492,8 +1497,9 @@ test('move selection with arrows', () => { test('overwrite selection with same value', () => { const {element} = setup(``) element.select() + element.focus() - userEvent.type(element, '11123') + userEvent.type(element, '11123', {skipClick: true}) expect(element).toHaveValue('11123') }) diff --git a/tests/type/modifiers.js b/tests/type/modifiers.js index 1be10468..c8b76adf 100644 --- a/tests/type/modifiers.js +++ b/tests/type/modifiers.js @@ -102,7 +102,7 @@ test('{backspace} triggers typing the backspace character and deletes the charac const {element, getEventSnapshot} = setup('') element.setSelectionRange(1, 1) - userEvent.type(element, '{backspace}') + userEvent.type(element, '{backspace}', {initialSelectionStart: 1}) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="o"] @@ -118,9 +118,11 @@ test('{backspace} triggers typing the backspace character and deletes the charac input[value="yo"] - mousedown input[value="yo"] - focus input[value="yo"] - focusin + input[value="yo"] - select input[value="yo"] - pointerup input[value="yo"] - mouseup input[value="yo"] - click + input[value="yo"] - select input[value="yo"] - keydown: Backspace (8) input[value="o"] - select input[value="o"] - input @@ -148,6 +150,7 @@ test('{backspace} on a readOnly input', () => { input[value="yo"] - mousedown input[value="yo"] - focus input[value="yo"] - focusin + input[value="yo"] - select input[value="yo"] - pointerup input[value="yo"] - mouseup input[value="yo"] - click @@ -178,6 +181,7 @@ test('{backspace} does not fire input if keydown prevents default', () => { input[value="yo"] - mousedown input[value="yo"] - focus input[value="yo"] - focusin + input[value="yo"] - select input[value="yo"] - pointerup input[value="yo"] - mouseup input[value="yo"] - click @@ -188,14 +192,15 @@ test('{backspace} does not fire input if keydown prevents default', () => { test('{backspace} deletes the selected range', () => { const {element, getEventSnapshot} = setup('') - element.setSelectionRange(1, 5) - userEvent.type(element, '{backspace}') + userEvent.type(element, '{backspace}', { + initialSelectionStart: 1, + initialSelectionEnd: 5, + }) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Here"] - input[value="Hi there"] - select input[value="Hi there"] - pointerover input[value="Hi there"] - pointerenter input[value="Hi there"] - mouseover @@ -206,9 +211,11 @@ test('{backspace} deletes the selected range', () => { input[value="Hi there"] - mousedown input[value="Hi there"] - focus input[value="Hi there"] - focusin + input[value="Hi there"] - select input[value="Hi there"] - pointerup input[value="Hi there"] - mouseup input[value="Hi there"] - click + input[value="Hi there"] - select input[value="Hi there"] - keydown: Backspace (8) input[value="Here"] - select input[value="Here"] - input @@ -931,6 +938,7 @@ test('{selectall} selects all the text', () => { input[value="abcdefg"] - mousedown input[value="abcdefg"] - focus input[value="abcdefg"] - focusin + input[value="abcdefg"] - select input[value="abcdefg"] - pointerup input[value="abcdefg"] - mouseup input[value="abcdefg"] - click @@ -961,9 +969,11 @@ test('{del} at the start of the input', () => { input[value="hello"] - mousedown input[value="hello"] - focus input[value="hello"] - focusin + input[value="hello"] - select input[value="hello"] - pointerup input[value="hello"] - mouseup input[value="hello"] - click + input[value="hello"] - select input[value="hello"] - keydown: Delete (46) input[value="ello"] - select input[value="ello"] - input @@ -991,10 +1001,10 @@ test('{del} at end of the input', () => { input[value="hello"] - mousedown input[value="hello"] - focus input[value="hello"] - focusin + input[value="hello"] - select input[value="hello"] - pointerup input[value="hello"] - mouseup input[value="hello"] - click - input[value="hello"] - select input[value="hello"] - keydown: Delete (46) input[value="hello"] - keyup: Delete (46) `) @@ -1023,6 +1033,7 @@ test('{del} in the middle of the input', () => { input[value="hello"] - mousedown input[value="hello"] - focus input[value="hello"] - focusin + input[value="hello"] - select input[value="hello"] - pointerup input[value="hello"] - mouseup input[value="hello"] - click @@ -1057,6 +1068,7 @@ test('{del} with a selection range', () => { input[value="hello"] - mousedown input[value="hello"] - focus input[value="hello"] - focusin + input[value="hello"] - select input[value="hello"] - pointerup input[value="hello"] - mouseup input[value="hello"] - click diff --git a/tests/utils/edit/calculateNewValue.ts b/tests/utils/edit/calculateNewValue.ts index c24cdc1b..7d1f4e50 100644 --- a/tests/utils/edit/calculateNewValue.ts +++ b/tests/utils/edit/calculateNewValue.ts @@ -93,10 +93,10 @@ test('honors maxlength with existing text', () => { input[value="12"] - mousedown input[value="12"] - focus input[value="12"] - focusin + input[value="12"] - select input[value="12"] - pointerup input[value="12"] - mouseup input[value="12"] - click - input[value="12"] - select input[value="12"] - keydown: 3 (51) input[value="12"] - keypress: 3 (51) input[value="12"] - keyup: 3 (51) @@ -123,10 +123,10 @@ test('honors maxlength on textarea', () => { textarea[value="12"] - mousedown textarea[value="12"] - focus textarea[value="12"] - focusin + textarea[value="12"] - select textarea[value="12"] - pointerup textarea[value="12"] - mouseup textarea[value="12"] - click - textarea[value="12"] - select textarea[value="12"] - keydown: 3 (51) textarea[value="12"] - keypress: 3 (51) textarea[value="12"] - keyup: 3 (51) From 96f273b7319a11409c117b8ca13686f9783968b4 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sun, 24 Oct 2021 21:29:13 +0000 Subject: [PATCH 2/5] change selection on different elements and with offset --- src/pointer/pointerAction.ts | 38 ++-- src/pointer/pointerMove.ts | 61 ++++++- src/pointer/pointerPress.ts | 170 ++++++++++++------ src/pointer/resolveSelectionTarget.ts | 73 ++++++++ src/pointer/types.ts | 26 ++- src/utils/pointer/fakeEvent.ts | 46 ++++- src/utils/pointer/firePointerEvents.ts | 22 +-- tests/pointer/index.ts | 230 +++++++++++++++++++++++++ 8 files changed, 555 insertions(+), 111 deletions(-) create mode 100644 src/pointer/resolveSelectionTarget.ts diff --git a/src/pointer/pointerAction.ts b/src/pointer/pointerAction.ts index f4df5e4d..8b55c0ea 100644 --- a/src/pointer/pointerAction.ts +++ b/src/pointer/pointerAction.ts @@ -1,12 +1,16 @@ -import {Coords, wait} from '../utils' +import {wait} from '../utils' import {pointerMove, PointerMoveAction} from './pointerMove' import {pointerPress, PointerPressAction} from './pointerPress' -import {inputDeviceState, pointerOptions, pointerState} from './types' +import { + inputDeviceState, + pointerOptions, + pointerState, + PointerTarget, + SelectionTarget, +} from './types' -export type PointerActionTarget = { - target?: Element - coords?: Partial -} +export type PointerActionTarget = Partial & + Partial export type PointerAction = PointerActionTarget & ( @@ -34,12 +38,11 @@ export async function pointerAction( const target = action.target ?? getPrevTarget(pointerName, state.pointerState) - const coords = completeCoords({ - ...(pointerName in state.pointerState.position + const coords = + action.coords ?? + (pointerName in state.pointerState.position ? state.pointerState.position[pointerName].coords - : undefined), - ...action.coords, - }) + : undefined) const promise = 'keyDef' in action @@ -70,16 +73,3 @@ function getPrevTarget(pointerName: string, state: pointerState) { return state.position[pointerName].target as Element } - -function completeCoords({ - x = 0, - y = 0, - clientX = x, - clientY = y, - offsetX = x, - offsetY = y, - pageX = clientX, - pageY = clientY, -}: Partial) { - return {x, y, clientX, clientY, offsetX, offsetY, pageX, pageY} -} diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts index c3ac7d9c..d61d1e96 100644 --- a/src/pointer/pointerMove.ts +++ b/src/pointer/pointerMove.ts @@ -1,17 +1,19 @@ +import {setUISelection} from '../document' import { - Coords, + PointerCoords, firePointerEvent, isDescendantOrSelf, isDisabled, } from '../utils' -import {inputDeviceState, PointerTarget} from './types' +import {resolveSelectionTarget} from './resolveSelectionTarget' +import {inputDeviceState, PointerTarget, SelectionTarget} from './types' -export interface PointerMoveAction extends PointerTarget { +export interface PointerMoveAction extends PointerTarget, SelectionTarget { pointerName?: string } export async function pointerMove( - {pointerName = 'mouse', target, coords}: PointerMoveAction, + {pointerName = 'mouse', target, coords, node, offset}: PointerMoveAction, {pointerState, keyboardState}: inputDeviceState, ): Promise { if (!(pointerName in pointerState.position)) { @@ -25,6 +27,7 @@ export async function pointerMove( pointerType, target: prevTarget, coords: prevCoords, + selectionRange, } = pointerState.position[pointerName] if (prevTarget && prevTarget !== target) { @@ -36,7 +39,13 @@ export async function pointerMove( } } - pointerState.position[pointerName] = {pointerId, pointerType, target, coords} + pointerState.position[pointerName] = { + pointerId, + pointerType, + target, + coords, + selectionRange, + } if (prevTarget !== target) { if (!prevTarget || !isDescendantOrSelf(prevTarget, target)) { @@ -49,14 +58,44 @@ export async function pointerMove( // Here we could probably calculate a few coords leading up to the final position fireMove(target, coords) - function fireMove(eventTarget: Element, eventCoords: Coords) { + if (selectionRange) { + const selectionFocus = resolveSelectionTarget({target, node, offset}) + if ( + 'node' in selectionRange && + selectionFocus.node === selectionRange.node + ) { + setUISelection( + selectionRange.node, + Math.min(selectionRange.start, selectionFocus.offset), + Math.max(selectionRange.end, selectionFocus.offset), + ) + } else if ('setEnd' in selectionRange) { + const range = selectionRange.cloneRange() + const cmp = selectionRange.comparePoint( + selectionFocus.node, + selectionFocus.offset, + ) + if (cmp < 0) { + range.setStart(selectionFocus.node, selectionFocus.offset) + } else if (cmp > 0) { + range.setEnd(selectionFocus.node, selectionFocus.offset) + } + + // TODO: support multiple ranges + 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: Coords) { + function fireLeave(eventTarget: Element, eventCoords?: PointerCoords) { fire(eventTarget, 'pointerout', eventCoords) fire(eventTarget, 'pointerleave', eventCoords) if (pointerType === 'mouse' && !isDisabled(eventTarget)) { @@ -65,7 +104,7 @@ export async function pointerMove( } } - function fireEnter(eventTarget: Element, eventCoords: Coords) { + function fireEnter(eventTarget: Element, eventCoords?: PointerCoords) { fire(eventTarget, 'pointerover', eventCoords) fire(eventTarget, 'pointerenter', eventCoords) if (pointerType === 'mouse' && !isDisabled(eventTarget)) { @@ -74,7 +113,11 @@ export async function pointerMove( } } - function fire(eventTarget: Element, type: string, eventCoords: Coords) { + function fire( + eventTarget: Element, + type: string, + eventCoords?: PointerCoords, + ) { return firePointerEvent(eventTarget, type, { pointerState, keyboardState, diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index 92472284..ccfb1e2b 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -2,7 +2,6 @@ import {fireEvent} from '@testing-library/dom' import { - Coords, findClosest, firePointerEvent, focus, @@ -16,18 +15,21 @@ import type { pointerKey, pointerState, PointerTarget, + SelectionTarget, } from './types' +import {resolveSelectionTarget} from './resolveSelectionTarget' -export interface PointerPressAction extends PointerTarget { +export interface PointerPressAction extends PointerTarget, SelectionTarget { keyDef: pointerKey releasePrevious: boolean releaseSelf: boolean } export async function pointerPress( - {keyDef, releasePrevious, releaseSelf, target, coords}: PointerPressAction, + action: PointerPressAction, state: inputDeviceState, ): Promise { + const {keyDef, target, releasePrevious, releaseSelf} = action const previous = state.pointerState.pressed.find(p => p.keyDef === keyDef) const pointerName = @@ -36,21 +38,14 @@ export async function pointerPress( const targetIsDisabled = isDisabled(target) if (previous) { - up(state, pointerName, keyDef, targetIsDisabled, target, coords, previous) + up(state, pointerName, action, previous, targetIsDisabled) } if (!releasePrevious) { - const press = down( - state, - pointerName, - keyDef, - targetIsDisabled, - target, - coords, - ) + const press = down(state, pointerName, action, targetIsDisabled) if (releaseSelf) { - up(state, pointerName, keyDef, targetIsDisabled, target, coords, press) + up(state, pointerName, action, press, targetIsDisabled) } } } @@ -63,15 +58,14 @@ function getNextPointerId(state: pointerState) { function down( {pointerState, keyboardState}: inputDeviceState, pointerName: string, - keyDef: pointerKey, + {keyDef, node, offset, target, coords}: PointerPressAction, targetIsDisabled: boolean, - target: Element, - coords: Coords, ) { const {name, pointerType, button} = keyDef const pointerId = pointerType === 'mouse' ? 1 : getNextPointerId(pointerState) pointerState.position[pointerName] = { + ...pointerState.position[pointerName], pointerId, pointerType, target, @@ -130,7 +124,14 @@ function down( } if (pointerType === 'mouse' && pressObj.unpreventedDefault) { - mousedownDefaultBehavior({target, targetIsDisabled, clickCount}) + mousedownDefaultBehavior({ + target, + targetIsDisabled, + clickCount, + position: pointerState.position[pointerName], + node, + offset, + }) } return pressObj @@ -152,11 +153,15 @@ function down( function up( {pointerState, keyboardState}: inputDeviceState, pointerName: string, - {pointerType, button}: pointerKey, - targetIsDisabled: boolean, - target: Element, - coords: Coords, + { + keyDef: {pointerType, button}, + target, + coords, + node, + offset, + }: PointerPressAction, pressed: pointerState['pressed'][number], + targetIsDisabled: boolean, ) { pointerState.pressed = pointerState.pressed.filter(p => p !== pressed) @@ -164,8 +169,7 @@ function up( let {unpreventedDefault} = pressed pointerState.position[pointerName] = { - pointerId, - pointerType, + ...pointerState.position[pointerName], target, coords, } @@ -195,7 +199,14 @@ function up( } if (unpreventedDefault && pointerType !== 'mouse' && !isMultiTouch) { - mousedownDefaultBehavior({target, targetIsDisabled, clickCount}) + mousedownDefaultBehavior({ + target, + targetIsDisabled, + clickCount, + position: pointerState.position[pointerName], + node, + offset, + }) } if (!targetIsDisabled) { @@ -240,13 +251,19 @@ function up( } function mousedownDefaultBehavior({ + position, target, targetIsDisabled, clickCount, + node, + offset, }: { + position: NonNullable target: Element targetIsDisabled: boolean clickCount: number + node?: Node + offset?: number }) { // The closest focusable element is focused when a `mousedown` would have been fired. // Even if there was no `mousedown` because the element was disabled. @@ -257,33 +274,86 @@ function mousedownDefaultBehavior({ // An unprevented mousedown moves the cursor to the closest character. // We try to approximate the behavior for a no-layout environment. - // TODO: implement for other elements - if (!targetIsDisabled && isElementType(target, ['input', 'textarea'])) { - const value = getUIValue(target) as string - const pos = value.length // might override this per option later - - if (clickCount % 3 === 1 || value.length === 0) { - setUISelection(target, pos, pos) - } else if (clickCount % 3 === 2) { - const isWhitespace = /\s/.test(value.substr(Math.max(0, pos - 1), 1)) - const before = ( - value - .substr(0, pos) - .match(isWhitespace ? /\s*$/ : /\S*$/) as RegExpMatchArray - )[0].length - const after = ( - value - .substr(pos) - .match(isWhitespace ? /^\s*/ : /^\S*/) as RegExpMatchArray - )[0].length - setUISelection(target, pos - before, pos + after) + 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) + + if (hasValue) { + setUISelection(target, start ?? text.length, end ?? text.length) + position.selectionRange = { + node: target, + start: start ?? 0, + end: end ?? text.length, + } } else { - const before = ( - value.substr(0, pos).match(/[^\r\n]*$/) as RegExpMatchArray - )[0].length - const after = (value.substr(pos).match(/^[\r\n]*/) as RegExpMatchArray)[0] - .length - setUISelection(target, pos - before, pos + after) + const {node: startNode, offset: startOffset} = resolveSelectionTarget({ + target, + node, + offset: start, + }) + const {node: endNode, offset: endOffset} = resolveSelectionTarget({ + target, + node, + offset: end, + }) + + const range = new Range() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + + position.selectionRange = range + + // TODO: support multiple ranges + const selection = target.ownerDocument.getSelection() as Selection + selection.removeAllRanges() + selection.addRange(range.cloneRange()) } } } + +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/resolveSelectionTarget.ts b/src/pointer/resolveSelectionTarget.ts new file mode 100644 index 00000000..849cebe5 --- /dev/null +++ b/src/pointer/resolveSelectionTarget.ts @@ -0,0 +1,73 @@ +import {getUIValue} from '../document' +import {isElementType} from '../utils' +import {PointerTarget, SelectionTarget} from './types' + +export function resolveSelectionTarget({ + target, + node, + offset, +}: PointerTarget & SelectionTarget) { + if (isElementType(target, ['input', 'textarea'])) { + return { + node: target, + offset: + offset ?? (getUIValue(target) ?? /* istanbul ignore next */ '').length, + } + } else if (node) { + return { + node, + offset: + offset ?? + (node.nodeType === 3 + ? (node.nodeValue as string).length + : node.childNodes.length), + } + } + + return findNodeAtTextOffset(target, offset) +} + +function findNodeAtTextOffset( + node: Node, + offset: number | undefined, + isRoot = true, +): { + node: Node + offset: number +} { + // When clicking after the content the browser behavior can be complicated: + // 1. If there is textContent after the last element child, + // the cursor is moved there. + // 2. If there is textContent in the last element child, + // the browser moves the cursor to the last non-empty text node inside this element. + // 3. Otherwise the cursor is moved to the end of the target. + + let i = offset === undefined ? node.childNodes.length - 1 : 0 + const step = offset === undefined ? -1 : +1 + + while ( + offset === undefined + ? i >= (isRoot ? Math.max(node.childNodes.length - 1, 0) : 0) + : i <= node.childNodes.length + ) { + const c = node.childNodes.item(i) + + const text = String(c.textContent) + if (text.length) { + if (offset !== undefined && text.length < offset) { + offset -= text.length + } else if (c.nodeType === 1) { + return findNodeAtTextOffset(c as Element, offset, false) + } else /* istanbul ignore else */ if (c.nodeType === 3) { + return { + node: c as Node, + offset: offset ?? (c.nodeValue as string).length, + } + } + } + + i += step + } + + return {node, offset: node.childNodes.length} +} diff --git a/src/pointer/types.ts b/src/pointer/types.ts index c3c65760..10da23e0 100644 --- a/src/pointer/types.ts +++ b/src/pointer/types.ts @@ -1,5 +1,5 @@ import {keyboardState} from '../keyboard/types' -import {Coords, MouseButton} from '../utils' +import {PointerCoords, MouseButton} from '../utils' /** * @internal Do not create/alter this by yourself as this type might be subject to changes. @@ -31,9 +31,9 @@ export type pointerState = { { pointerId: number pointerType: 'mouse' | 'pen' | 'touch' - target?: Element - coords: Coords - } + } & Partial & { + selectionRange?: Range | SelectionInputRange + } > /** @@ -60,7 +60,23 @@ export interface pointerKey { export interface PointerTarget { target: Element - coords: Coords + 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 } export interface inputDeviceState { diff --git a/src/utils/pointer/fakeEvent.ts b/src/utils/pointer/fakeEvent.ts index f6aa553b..138ec1a1 100644 --- a/src/utils/pointer/fakeEvent.ts +++ b/src/utils/pointer/fakeEvent.ts @@ -1,6 +1,6 @@ // See : https://github.com/testing-library/react-testing-library/issues/268 -export interface FakeEventInit extends MouseEventInit, PointerEventInit { +export interface PointerCoords { x?: number y?: number clientX?: number @@ -9,9 +9,19 @@ export interface FakeEventInit extends MouseEventInit, PointerEventInit { offsetY?: number pageX?: number pageY?: number + screenX?: number + screenY?: number } -function assignProps(obj: MouseEvent | PointerEvent, props: FakeEventInit) { +export interface FakePointerEventInit + extends MouseEventInit, + PointerEventInit, + PointerCoords {} + +function assignProps( + obj: MouseEvent | PointerEvent, + props: FakePointerEventInit, +) { for (const [key, value] of Object.entries(props)) { Object.defineProperty(obj, key, {get: () => value}) } @@ -19,9 +29,7 @@ function assignProps(obj: MouseEvent | PointerEvent, props: FakeEventInit) { function assignPositionInit( obj: MouseEvent | PointerEvent, - {x, y, clientX, clientY, offsetX, offsetY, pageX, pageY}: FakeEventInit, -) { - assignProps(obj, { + { x, y, clientX, @@ -30,12 +38,29 @@ function assignPositionInit( offsetY, pageX, pageY, + screenX, + screenY, + }: FakePointerEventInit, +) { + assignProps(obj, { + /* istanbul ignore start */ + x: x ?? clientX ?? 0, + y: y ?? clientY ?? 0, + clientX: x ?? clientX ?? 0, + clientY: y ?? clientY ?? 0, + offsetX: offsetX ?? 0, + offsetY: offsetY ?? 0, + pageX: pageX ?? 0, + pageY: pageY ?? 0, + screenX: screenX ?? 0, + screenY: screenY ?? 0, + /* istanbul ignore end */ }) } function assignPointerInit( obj: MouseEvent | PointerEvent, - {isPrimary, pointerId, pointerType}: FakeEventInit, + {isPrimary, pointerId, pointerType}: FakePointerEventInit, ) { assignProps(obj, { isPrimary, @@ -46,7 +71,10 @@ function assignPointerInit( const notBubbling = ['mouseenter', 'mouseleave', 'pointerenter', 'pointerleave'] -function getInitDefaults(type: string, init: FakeEventInit): FakeEventInit { +function getInitDefaults( + type: string, + init: FakePointerEventInit, +): FakePointerEventInit { return { bubbles: !notBubbling.includes(type), cancelable: true, @@ -56,7 +84,7 @@ function getInitDefaults(type: string, init: FakeEventInit): FakeEventInit { } export class FakeMouseEvent extends MouseEvent { - constructor(type: string, init: FakeEventInit) { + constructor(type: string, init: FakePointerEventInit) { super(type, getInitDefaults(type, init)) assignPositionInit(this, init) } @@ -64,7 +92,7 @@ export class FakeMouseEvent extends MouseEvent { // Should extend PointerEvent, but... https://github.com/jsdom/jsdom/issues/2527 export class FakePointerEvent extends MouseEvent { - constructor(type: string, init: FakeEventInit) { + constructor(type: string, init: FakePointerEventInit) { super(type, getInitDefaults(type, init)) assignPositionInit(this, init) assignPointerInit(this, init) diff --git a/src/utils/pointer/firePointerEvents.ts b/src/utils/pointer/firePointerEvents.ts index 05c960b0..07f3999d 100644 --- a/src/utils/pointer/firePointerEvents.ts +++ b/src/utils/pointer/firePointerEvents.ts @@ -1,20 +1,14 @@ import {fireEvent} from '@testing-library/dom' import type {pointerState} from '../../pointer/types' import type {keyboardState} from '../../keyboard/types' -import {FakeEventInit, FakeMouseEvent, FakePointerEvent} from './fakeEvent' +import { + FakeMouseEvent, + FakePointerEvent, + FakePointerEventInit, + PointerCoords, +} from './fakeEvent' import {getMouseButton, getMouseButtons, MouseButton} from './mouseButtons' -export interface Coords { - x: number - y: number - clientX: number - clientY: number - offsetX: number - offsetY: number - pageX: number - pageY: number -} - export function firePointerEvent( target: Element, type: string, @@ -32,7 +26,7 @@ export function firePointerEvent( keyboardState: keyboardState pointerType?: 'mouse' | 'pen' | 'touch' button?: MouseButton - coords: Coords + coords?: PointerCoords pointerId?: number isPrimary?: boolean clickCount?: number @@ -43,7 +37,7 @@ export function firePointerEvent( ? FakeMouseEvent : FakePointerEvent - const init: FakeEventInit = { + const init: FakePointerEventInit = { ...coords, altKey: keyboardState.modifiers.alt, ctrlKey: keyboardState.modifiers.ctrl, diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index 6137c684..5f5a36e0 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -439,6 +439,24 @@ describe('mousedown moves selection', () => { expect(element).toHaveProperty('selectionStart', 8) expect(element).toHaveProperty('selectionEnd', 11) + userEvent.pointer({ + keys: '[MouseLeft][MouseLeft]', + target: element, + offset: 0, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 3) + + userEvent.pointer({ + keys: '[MouseLeft][MouseLeft]', + target: element, + offset: 11, + }) + + expect(element).toHaveProperty('selectionStart', 8) + expect(element).toHaveProperty('selectionEnd', 11) + element.value = 'foo bar ' userEvent.pointer({keys: '[MouseLeft][MouseLeft]', target: element}) @@ -457,5 +475,217 @@ describe('mousedown moves selection', () => { expect(element).toHaveProperty('selectionStart', 0) expect(element).toHaveProperty('selectionEnd', 11) + + userEvent.pointer({ + keys: '[MouseLeft][MouseLeft][MouseLeft]', + target: element, + offset: 0, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 11) + + userEvent.pointer({ + keys: '[MouseLeft][MouseLeft][MouseLeft]', + target: element, + offset: 11, + }) + + expect(element).toHaveProperty('selectionStart', 0) + expect(element).toHaveProperty('selectionEnd', 11) + }) + + test('mousemove with pressed button extends selection', () => { + const {element} = setup(``) + + const pointerState = userEvent.pointer({ + keys: '[MouseLeft][MouseLeft]', + target: element, + offset: 6, + }) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 7) + + userEvent.pointer({offset: 2}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 2) + expect(element).toHaveProperty('selectionEnd', 7) + + userEvent.pointer({offset: 10}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 10) + }) + + test('selection is moved on non-input elements', () => { + const {element} = setup( + `
foo bar baz
`, + ) + const span = element.querySelectorAll('span') + + const pointerState = userEvent.pointer({ + keys: '[MouseLeft][MouseLeft]', + target: element, + offset: 6, + }) + + expect(document.getSelection()?.toString()).toBe('bar') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[1].previousSibling, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[1].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 3, + ) + + userEvent.pointer({offset: 2}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('o bar') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 2, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[1].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 3, + ) + + userEvent.pointer({offset: 10}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('bar ba') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[1].previousSibling, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[2].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 2, + ) + + userEvent.pointer({}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('bar baz') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[1].previousSibling, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[2].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 3, + ) + }) + + test('`node` overrides the text offset approximation', () => { + const {element} = setup( + `
foo bar
baz
`, + ) + const div = element.firstChild as HTMLDivElement + const span = element.querySelectorAll('span') + + const pointerState = userEvent.pointer({ + keys: '[MouseLeft]', + target: element, + node: span[0].firstChild as Node, + offset: 1, + }) + userEvent.pointer({node: div, offset: 3}, {pointerState}) + + expect(document.getSelection()?.toString()).toBe('oo bar') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + div, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 3, + ) + + userEvent.pointer({ + keys: '[MouseLeft]', + target: element, + node: span[0].firstChild as Node, + }) + expect(document.getSelection()?.toString()).toBe('') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 3, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[0].firstChild, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 3, + ) + + userEvent.pointer({ + keys: '[MouseLeft]', + target: element, + node: span[0] as Node, + }) + expect(document.getSelection()?.toString()).toBe('') + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startContainer', + span[0], + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'startOffset', + 1, + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endContainer', + span[0], + ) + expect(document.getSelection()?.getRangeAt(0)).toHaveProperty( + 'endOffset', + 1, + ) }) }) From baf10848ce25c1a029e41cf23678befcb7c8dfe6 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sun, 24 Oct 2021 21:50:26 +0000 Subject: [PATCH 3/5] stop moving selection after pointerup --- src/pointer/pointerPress.ts | 2 ++ tests/pointer/index.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index ccfb1e2b..179bcb8c 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -209,6 +209,8 @@ function up( }) } + delete pointerState.position[pointerName].selectionRange + if (!targetIsDisabled) { if (pointerType === 'mouse' || !isMultiTouch) { unpreventedDefault = fire('mouseup') && unpreventedDefault diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index 5f5a36e0..1ca5a418 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -499,7 +499,7 @@ describe('mousedown moves selection', () => { const {element} = setup(``) const pointerState = userEvent.pointer({ - keys: '[MouseLeft][MouseLeft]', + keys: '[MouseLeft][MouseLeft>]', target: element, offset: 6, }) @@ -525,7 +525,7 @@ describe('mousedown moves selection', () => { const span = element.querySelectorAll('span') const pointerState = userEvent.pointer({ - keys: '[MouseLeft][MouseLeft]', + keys: '[MouseLeft][MouseLeft>]', target: element, offset: 6, }) @@ -617,7 +617,7 @@ describe('mousedown moves selection', () => { const span = element.querySelectorAll('span') const pointerState = userEvent.pointer({ - keys: '[MouseLeft]', + keys: '[MouseLeft>]', target: element, node: span[0].firstChild as Node, offset: 1, From 835801fd050425a1842bb1809a9628c03f2c02ce Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sun, 24 Oct 2021 21:51:53 +0000 Subject: [PATCH 4/5] refactor --- src/pointer/pointerMove.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts index d61d1e96..f549958f 100644 --- a/src/pointer/pointerMove.ts +++ b/src/pointer/pointerMove.ts @@ -40,11 +40,9 @@ export async function pointerMove( } pointerState.position[pointerName] = { - pointerId, - pointerType, + ...pointerState.position[pointerName], target, coords, - selectionRange, } if (prevTarget !== target) { From 37490aafd8fa6993cc41082ce058be95006a4359 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Sun, 24 Oct 2021 22:05:50 +0000 Subject: [PATCH 5/5] fix coverage --- src/pointer/pointerMove.ts | 2 +- tests/pointer/index.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts index f549958f..aafb6ecb 100644 --- a/src/pointer/pointerMove.ts +++ b/src/pointer/pointerMove.ts @@ -67,7 +67,7 @@ export async function pointerMove( Math.min(selectionRange.start, selectionFocus.offset), Math.max(selectionRange.end, selectionFocus.offset), ) - } else if ('setEnd' in selectionRange) { + } else /* istanbul ignore else */ if ('setEnd' in selectionRange) { const range = selectionRange.cloneRange() const cmp = selectionRange.comparePoint( selectionFocus.node, diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index 1ca5a418..4fedaa1d 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -516,6 +516,11 @@ describe('mousedown moves selection', () => { expect(element).toHaveProperty('selectionStart', 4) expect(element).toHaveProperty('selectionEnd', 10) + + userEvent.pointer({}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 11) }) test('selection is moved on non-input elements', () => {