From 555000da294111f96a1782ab39b5f82596708984 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Tue, 19 Oct 2021 21:31:43 +0200 Subject: [PATCH] feat: add `pointer` API (#750) * feat: add `pointer` API * refactor * add `pointerMove` * fix fake event bubbling * sequences of pointer actions * default to mouse pointer * add touch tests * add enter/leave events on touch down/up * add tests and handle errors * add tests * fix istanbul ignore see kentcdodds/kcd-scripts#218 * add test --- src/__tests__/click.js | 30 +- src/__tests__/dblclick.js | 42 +- src/__tests__/helpers/utils.ts | 12 +- src/__tests__/pointer/index.ts | 389 ++++++++++++++++++ src/__tests__/setup.ts | 36 ++ .../utils/misc/isDescendantOrSelf.ts | 25 ++ src/keyboard/getNextKeyDef.ts | 154 +------ src/pointer/index.ts | 102 +++++ src/pointer/keyMap.ts | 10 + src/pointer/parseKeyDef.ts | 28 ++ src/pointer/pointerAction.ts | 84 ++++ src/pointer/pointerMove.ts | 82 ++++ src/pointer/pointerPress.ts | 183 ++++++++ src/pointer/types.ts | 63 +++ src/setup.ts | 26 +- src/utils/index.ts | 7 + src/utils/keyDef/readNextDescriptor.ts | 140 +++++++ src/utils/misc/isDescendantOrSelf.ts | 15 + src/utils/pointer/fakeEvent.ts | 72 ++++ src/utils/pointer/firePointerEvents.ts | 62 +++ src/utils/pointer/mouseButtons.ts | 25 ++ 21 files changed, 1399 insertions(+), 188 deletions(-) create mode 100644 src/__tests__/pointer/index.ts create mode 100644 src/__tests__/utils/misc/isDescendantOrSelf.ts create mode 100644 src/pointer/index.ts create mode 100644 src/pointer/keyMap.ts create mode 100644 src/pointer/parseKeyDef.ts create mode 100644 src/pointer/pointerAction.ts create mode 100644 src/pointer/pointerMove.ts create mode 100644 src/pointer/pointerPress.ts create mode 100644 src/pointer/types.ts create mode 100644 src/utils/keyDef/readNextDescriptor.ts create mode 100644 src/utils/misc/isDescendantOrSelf.ts create mode 100644 src/utils/pointer/fakeEvent.ts create mode 100644 src/utils/pointer/firePointerEvents.ts create mode 100644 src/utils/pointer/mouseButtons.ts diff --git a/src/__tests__/click.js b/src/__tests__/click.js index 21cd2b2d..5db0f0e1 100644 --- a/src/__tests__/click.js +++ b/src/__tests__/click.js @@ -370,15 +370,15 @@ test('fires mouse events with the correct properties', () => { const {element, getClickEventsSnapshot} = setup('
') userEvent.click(element) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerenter + pointerover - pointerId=undefined; pointerType=undefined; isPrimary=undefined + pointerenter - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 - pointermove + pointermove - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousemove - button=0; buttons=0; detail=0 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=0; buttons=1; detail=1 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=0; buttons=1; detail=1 click - button=0; buttons=1; detail=1 `) @@ -391,15 +391,15 @@ test('fires mouse events with custom button property', () => { altKey: true, }) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerenter + pointerover - pointerId=undefined; pointerType=undefined; isPrimary=undefined + pointerenter - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 - pointermove + pointermove - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousemove - button=0; buttons=0; detail=0 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=1; buttons=4; detail=1 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=1; buttons=4; detail=1 click - button=1; buttons=4; detail=1 `) @@ -410,15 +410,15 @@ test('fires mouse events with custom buttons property', () => { userEvent.click(element, {buttons: 4}) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerenter + pointerover - pointerId=undefined; pointerType=undefined; isPrimary=undefined + pointerenter - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 - pointermove + pointermove - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousemove - button=0; buttons=0; detail=0 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=1; buttons=4; detail=1 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=1; buttons=4; detail=1 click - button=1; buttons=4; detail=1 `) diff --git a/src/__tests__/dblclick.js b/src/__tests__/dblclick.js index c234fffc..9e5e7f0a 100644 --- a/src/__tests__/dblclick.js +++ b/src/__tests__/dblclick.js @@ -208,20 +208,20 @@ test('fires mouse events with the correct properties', () => { const {element, getClickEventsSnapshot} = setup('
') userEvent.dblClick(element) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerenter + pointerover - pointerId=undefined; pointerType=undefined; isPrimary=undefined + pointerenter - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 - pointermove + pointermove - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousemove - button=0; buttons=0; detail=0 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=0; buttons=1; detail=1 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=0; buttons=1; detail=1 click - button=0; buttons=1; detail=1 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=0; buttons=1; detail=2 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=0; buttons=1; detail=2 click - button=0; buttons=1; detail=2 dblclick - button=0; buttons=1; detail=2 @@ -235,20 +235,20 @@ test('fires mouse events with custom button property', () => { altKey: true, }) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerenter + pointerover - pointerId=undefined; pointerType=undefined; isPrimary=undefined + pointerenter - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 - pointermove + pointermove - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousemove - button=0; buttons=0; detail=0 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=1; buttons=4; detail=1 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=1; buttons=4; detail=1 click - button=1; buttons=4; detail=1 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=1; buttons=4; detail=2 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=1; buttons=4; detail=2 click - button=1; buttons=4; detail=2 dblclick - button=1; buttons=4; detail=2 @@ -261,20 +261,20 @@ test('fires mouse events with custom buttons property', () => { userEvent.dblClick(element, {buttons: 4}) expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` - pointerover - pointerenter + pointerover - pointerId=undefined; pointerType=undefined; isPrimary=undefined + pointerenter - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseover - button=0; buttons=0; detail=0 mouseenter - button=0; buttons=0; detail=0 - pointermove + pointermove - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousemove - button=0; buttons=0; detail=0 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=1; buttons=4; detail=1 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=1; buttons=4; detail=1 click - button=1; buttons=4; detail=1 - pointerdown + pointerdown - pointerId=undefined; pointerType=undefined; isPrimary=undefined mousedown - button=1; buttons=4; detail=2 - pointerup + pointerup - pointerId=undefined; pointerType=undefined; isPrimary=undefined mouseup - button=1; buttons=4; detail=2 click - button=1; buttons=4; detail=2 dblclick - button=1; buttons=4; detail=2 diff --git a/src/__tests__/helpers/utils.ts b/src/__tests__/helpers/utils.ts index 5f0c36fb..b4513fa7 100644 --- a/src/__tests__/helpers/utils.ts +++ b/src/__tests__/helpers/utils.ts @@ -238,7 +238,15 @@ function isElement(target: EventTarget): target is Element { } function isMouseEvent(event: Event): event is MouseEvent { - return event.constructor.name === 'MouseEvent' + return ( + event.constructor.name === 'MouseEvent' || + event.type === 'click' || + event.type.startsWith('mouse') + ) +} + +function isPointerEvent(event: Event): event is PointerEvent { + return event.type.startsWith('pointer') } function addListeners( @@ -340,6 +348,8 @@ function addListeners( const lines = getEvents().map(e => isMouseEvent(e) ? `${e.type} - button=${e.button}; buttons=${e.buttons}; detail=${e.detail}` + : isPointerEvent(e) + ? `${e.type} - pointerId=${e.pointerId}; pointerType=${e.pointerType}; isPrimary=${e.isPrimary}` : e.type, ) return {snapshot: lines.join('\n')} diff --git a/src/__tests__/pointer/index.ts b/src/__tests__/pointer/index.ts new file mode 100644 index 00000000..c9a0bcdb --- /dev/null +++ b/src/__tests__/pointer/index.ts @@ -0,0 +1,389 @@ +import {wait} from 'utils' +import userEvent from '../../index' +import {setup} from '../helpers/utils' + +test('double click', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer({keys: '[MouseLeft][MouseLeft]', target: element}) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=2 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=2 + click - button=0; buttons=0; detail=2 + `) +}) + +test('two clicks', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + const pointerState = userEvent.pointer({keys: '[MouseLeft]', target: element}) + userEvent.pointer({keys: '[MouseLeft]', target: element}, {pointerState}) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + `) +}) + +test('drag sequence', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer([ + {keys: '[MouseLeft>]', target: element}, + {coords: {x: 20, y: 20}}, + '[/MouseLeft]', + ]) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointermove - pointerId=1; pointerType=mouse; isPrimary=undefined + mousemove - button=0; buttons=0; detail=0 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + `) +}) + +test('hover to other element', () => { + const {elements, getEventSnapshot} = setup(`
`) + + userEvent.pointer([ + {target: elements[0], coords: {x: 20, y: 20}}, + {target: elements[1], coords: {x: 40, y: 40}}, + ]) + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: div + + div - pointerenter + div - mouseenter + div - pointermove + div - mousemove + div - pointermove + div - mousemove + div - pointerleave + div - mouseleave + div - pointerenter + div - mouseenter + div - pointermove + div - mousemove + `) +}) + +test('hover inside element', () => { + const {element, getEventSnapshot} = setup(`

`) + + userEvent.pointer([ + {target: element}, + {target: element.firstChild as Element}, + {target: element.lastChild as Element}, + {target: element}, + ]) + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: div + + div - pointerover + div - pointerenter + div - mouseover + div - mouseenter + div - pointermove + div - mousemove + div - pointermove + div - mousemove + a - pointerenter + a - mouseenter + a - pointermove + a - mousemove + a - pointermove + a - mousemove + a - pointerleave + a - mouseleave + p - pointerenter + p - mouseenter + p - pointermove + p - mousemove + p - pointermove + p - mousemove + p - pointerleave + p - mouseleave + div - pointermove + div - mousemove + `) +}) + +test('continue previous target', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + const pointerState = userEvent.pointer({keys: '[MouseLeft]', target: element}) + userEvent.pointer('[MouseLeft]', {pointerState}) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + `) +}) + +test('other keys reset click counter, but keyup/click still uses the old count', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer({ + keys: '[MouseLeft][MouseLeft>][MouseRight][MouseLeft]', + target: element, + }) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=2 + mousedown - button=1; buttons=0; detail=1 + mouseup - button=1; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=2 + click - button=0; buttons=0; detail=2 + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + `) +}) + +test('click per touch device', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer({keys: '[TouchA]', target: element}) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerover - pointerId=2; pointerType=touch; isPrimary=undefined + pointerenter - pointerId=2; pointerType=touch; isPrimary=undefined + 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 + 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 + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + `) +}) + +test('double click per touch device', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer({keys: '[TouchA][TouchA]', target: element}) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerover - pointerId=2; pointerType=touch; isPrimary=undefined + pointerenter - pointerId=2; pointerType=touch; isPrimary=undefined + 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 + 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 + 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 + 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 + mouseup - button=0; buttons=0; detail=2 + click - button=0; buttons=0; detail=2 + `) +}) + +test('multi touch does not click', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer({keys: '[TouchA>][TouchB][/TouchA]', target: element}) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerover - pointerId=2; pointerType=touch; isPrimary=undefined + pointerenter - pointerId=2; pointerType=touch; isPrimary=undefined + pointerdown - pointerId=2; pointerType=touch; isPrimary=true + pointerover - pointerId=3; pointerType=touch; isPrimary=undefined + pointerenter - pointerId=3; pointerType=touch; isPrimary=undefined + pointerdown - pointerId=3; pointerType=touch; isPrimary=false + pointerup - pointerId=3; pointerType=touch; isPrimary=false + pointerout - pointerId=3; pointerType=touch; isPrimary=undefined + pointerleave - pointerId=3; pointerType=touch; isPrimary=undefined + pointerup - pointerId=2; pointerType=touch; isPrimary=true + pointerout - pointerId=2; pointerType=touch; isPrimary=undefined + pointerleave - pointerId=2; pointerType=touch; isPrimary=undefined + `) +}) + +test('drag touch', () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + userEvent.pointer([ + {keys: '[TouchA>]', target: element}, + {pointerName: 'TouchA', coords: {x: 20, y: 20}}, + '[/TouchA]', + ]) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerover - pointerId=2; pointerType=touch; isPrimary=undefined + pointerenter - pointerId=2; pointerType=touch; isPrimary=undefined + pointerdown - pointerId=2; pointerType=touch; isPrimary=true + pointermove - pointerId=2; pointerType=touch; isPrimary=undefined + pointerup - pointerId=2; pointerType=touch; isPrimary=true + pointerout - pointerId=2; pointerType=touch; isPrimary=undefined + pointerleave - pointerId=2; pointerType=touch; isPrimary=undefined + 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 + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + `) +}) + +test('move touch over elements', () => { + const {element, getEventSnapshot} = setup(`

`) + + userEvent.pointer([ + {keys: '[TouchA>]', target: element}, + {pointerName: 'TouchA', target: element.firstChild as Element}, + {pointerName: 'TouchA', target: element.lastChild as Element}, + {pointerName: 'TouchA', target: element}, + {keys: '[/TouchA]', target: element}, + ]) + + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: div + + div - pointerover + div - pointerenter + div - pointerdown + div - pointermove + a - pointerenter + a - pointermove + a - pointermove + a - pointerleave + p - pointerenter + p - pointermove + p - pointermove + p - pointerleave + div - pointermove + div - pointerup + div - pointerout + div - pointerleave + div - mouseover + div - mouseenter + div - mousemove + div - mousedown + div - mouseup + div - click + `) +}) + +test('unknown button does nothing', () => { + const {element, getEvents} = setup(`
`) + + userEvent.pointer({keys: '[foo]', target: element}) + + expect(getEvents()).toEqual([]) +}) + +test('pointer without previous target results in error', async () => { + await expect( + userEvent.pointer({keys: '[MouseLeft]'}, {delay: 1}), + ).rejects.toThrowError('no previous position') +}) + +describe('error', () => { + afterEach(() => { + ;(console.error as jest.MockedFunction).mockClear() + }) + + it('error for unknown pointer in sync', async () => { + const err = jest.spyOn(console, 'error') + err.mockImplementation(() => {}) + + const {element} = setup(`
`) + userEvent.pointer({pointerName: 'foo', target: element}) + + // the catch will be asynchronous + await wait(10) + + expect(err).toHaveBeenCalledWith(expect.any(Error) as unknown) + expect(err.mock.calls[0][0]).toHaveProperty( + 'message', + expect.stringContaining('does not exist'), + ) + }) + + it('error for unknown pointer in async', async () => { + const {element} = setup(`
`) + const promise = userEvent.pointer( + {pointerName: 'foo', target: element}, + {delay: 1}, + ) + + return expect(promise).rejects.toThrowError('does not exist') + }) +}) + +test('asynchronous pointer', async () => { + const {element, getClickEventsSnapshot} = setup(`
`) + + // eslint-disable-next-line testing-library/no-await-sync-events + const pointerState = await userEvent.pointer( + {keys: '[MouseLeft]', target: element}, + {delay: 1}, + ) + // eslint-disable-next-line testing-library/no-await-sync-events + await userEvent.pointer([{coords: {x: 20, y: 20}}, '[/MouseLeft]'], { + delay: 1, + pointerState, + }) + + expect(getClickEventsSnapshot()).toMatchInlineSnapshot(` + pointerdown - pointerId=1; pointerType=mouse; isPrimary=true + mousedown - button=0; buttons=0; detail=1 + pointerup - pointerId=1; pointerType=mouse; isPrimary=true + mouseup - button=0; buttons=0; detail=1 + click - button=0; buttons=0; detail=1 + pointermove - pointerId=1; pointerType=mouse; isPrimary=undefined + mousemove - button=0; buttons=0; detail=0 + `) +}) diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 14ee1f10..c3266d8f 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -20,6 +20,7 @@ import '../click' import '../hover' import '../keyboard' import '../paste' +import '../pointer' import '../select-options' import '../tab' import '../type' @@ -73,6 +74,7 @@ jest.mock('../click', () => mockApis('../click', 'click', 'dblClick')) jest.mock('../hover', () => mockApis('../hover', 'hover', 'unhover')) jest.mock('../keyboard', () => mockApis('../keyboard', 'keyboard')) jest.mock('../paste', () => mockApis('../paste', 'paste')) +jest.mock('../pointer', () => mockApis('../pointer', 'pointer')) jest.mock('../select-options', () => mockApis('../select-options', 'selectOptions', 'deselectOptions'), ) @@ -202,6 +204,17 @@ cases( }, }, paste: {api: 'paste', args: [null, 'foo'], elementArg: 0}, + pointer: { + api: 'pointer', + args: ['foo'], + optionsArg: 1, + options: { + pointerMap: [{name: 'x', pointerType: 'touch'}], + }, + optionsSub: { + pointerMap: [{name: 'y', pointerType: 'touch'}], + }, + }, selectOptions: { api: 'selectOptions', args: [null, ['foo']], @@ -271,3 +284,26 @@ test('maintain `keyboardState` through different api calls', async () => { // if the state is shared through api the already pressed `b` is automatically released expect(getEvents('keyup')).toHaveLength(3) }) + +test('maintain `pointerState` through different api calls', async () => { + const {element, getEvents} = setup(``) + + const api = userEvent.setup() + + expect(api.pointer({keys: '[MouseLeft>]', target: element})).toBe(undefined) + + expect(getSpy('pointer')).toBeCalledTimes(1) + expect(getEvents('mousedown')).toHaveLength(1) + expect(getEvents('mouseup')).toHaveLength(0) + + await expect(api.pointer('[/MouseLeft]', {delay: 1})).resolves.toBe(undefined) + + expect(getSpy('pointer')).toBeCalledTimes(2) + expect(getEvents('mousedown')).toHaveLength(1) + expect(getEvents('mouseup')).toHaveLength(1) + + api.setup({}).pointer({target: element.ownerDocument.body}) + + expect(getSpy('pointer')).toBeCalledTimes(3) + expect(getEvents('mouseleave')).toHaveLength(1) +}) diff --git a/src/__tests__/utils/misc/isDescendantOrSelf.ts b/src/__tests__/utils/misc/isDescendantOrSelf.ts new file mode 100644 index 00000000..937746ca --- /dev/null +++ b/src/__tests__/utils/misc/isDescendantOrSelf.ts @@ -0,0 +1,25 @@ +import {setup} from '__tests__/helpers/utils' +import {isDescendantOrSelf} from '../../../utils' + +test('isDescendantOrSelf', () => { + setup(`

`) + + expect( + isDescendantOrSelf( + document.querySelector('span') as Element, + document.querySelector('a') as Element, + ), + ).toBe(false) + expect( + isDescendantOrSelf( + document.querySelector('span') as Element, + document.querySelector('div') as Element, + ), + ).toBe(true) + expect( + isDescendantOrSelf( + document.querySelector('span') as Element, + document.querySelector('span') as Element, + ), + ).toBe(true) +}) diff --git a/src/keyboard/getNextKeyDef.ts b/src/keyboard/getNextKeyDef.ts index 4e6a2e09..e4b458d0 100644 --- a/src/keyboard/getNextKeyDef.ts +++ b/src/keyboard/getNextKeyDef.ts @@ -1,10 +1,6 @@ +import {readNextDescriptor} from '../utils' import {keyboardKey, keyboardOptions} from './types' -enum bracketDict { - '{' = '}', - '[' = ']', -} - enum legacyModifiers { 'alt' = 'alt', 'ctrl' = 'ctrl', @@ -45,7 +41,9 @@ export function getNextKeyDef( descriptor, consumedLength, releasePrevious, - releaseSelf, + releaseSelf = !( + type === '{' && getEnumValue(legacyModifiers, descriptor.toLowerCase()) + ), repeat, } = readNextDescriptor(text) @@ -72,154 +70,10 @@ export function getNextKeyDef( } } -function readNextDescriptor(text: string) { - let pos = 0 - const startBracket = - text[pos] in bracketDict ? (text[pos] as keyof typeof bracketDict) : '' - - pos += startBracket.length - - // `foo{{bar` is an escaped char at position 3, - // but `foo{{{>5}bar` should be treated as `{` pressed down for 5 keydowns. - const startBracketRepeated = startBracket - ? (text.match(new RegExp(`^\\${startBracket}+`)) as RegExpMatchArray)[0] - .length - : 0 - const isEscapedChar = - startBracketRepeated === 2 || - (startBracket === '{' && startBracketRepeated > 3) - - const type = isEscapedChar ? '' : startBracket - - return { - type, - ...(type === '' ? readPrintableChar(text, pos) : readTag(text, pos, type)), - } -} - -function readPrintableChar(text: string, pos: number) { - const descriptor = text[pos] - - assertDescriptor(descriptor, text, pos) - - pos += descriptor.length - - return { - consumedLength: pos, - descriptor, - releasePrevious: false, - releaseSelf: true, - repeat: 1, - } -} - -function readTag( - text: string, - pos: number, - startBracket: keyof typeof bracketDict, -) { - const releasePreviousModifier = text[pos] === '/' ? '/' : '' - - pos += releasePreviousModifier.length - - const descriptor = text.slice(pos).match(/^\w+/)?.[0] - - assertDescriptor(descriptor, text, pos) - - pos += descriptor.length - - const repeatModifier = text.slice(pos).match(/^>\d+/)?.[0] ?? '' - - pos += repeatModifier.length - - const releaseSelfModifier = - text[pos] === '/' || (!repeatModifier && text[pos] === '>') ? text[pos] : '' - - pos += releaseSelfModifier.length - - const expectedEndBracket = bracketDict[startBracket] - const endBracket = text[pos] === expectedEndBracket ? expectedEndBracket : '' - - if (!endBracket) { - throw new Error( - getErrorMessage( - [ - !repeatModifier && 'repeat modifier', - !releaseSelfModifier && 'release modifier', - `"${expectedEndBracket}"`, - ] - .filter(Boolean) - .join(' or '), - text[pos], - text, - ), - ) - } - - pos += endBracket.length - - return { - consumedLength: pos, - descriptor, - releasePrevious: !!releasePreviousModifier, - repeat: repeatModifier ? Math.max(Number(repeatModifier.substr(1)), 1) : 1, - releaseSelf: hasReleaseSelf( - startBracket, - descriptor, - releaseSelfModifier, - repeatModifier, - ), - } -} - -function assertDescriptor( - descriptor: string | undefined, - text: string, - pos: number, -): asserts descriptor is string { - if (!descriptor) { - throw new Error(getErrorMessage('key descriptor', text[pos], text)) - } -} - function getEnumValue(f: Record, key: string): T | undefined { return f[key] } -function hasReleaseSelf( - startBracket: keyof typeof bracketDict, - descriptor: string, - releaseSelfModifier: string, - repeatModifier: string, -) { - if (releaseSelfModifier) { - return releaseSelfModifier === '/' - } - - if (repeatModifier) { - return false - } - - if ( - startBracket === '{' && - getEnumValue(legacyModifiers, descriptor.toLowerCase()) - ) { - return false - } - - return true -} - function mapLegacyKey(descriptor: string) { return getEnumValue(legacyKeyMap, descriptor) ?? descriptor } - -function getErrorMessage( - expected: string, - found: string | undefined, - text: string, -) { - return `Expected ${expected} but found "${found ?? ''}" in "${text}" - See https://github.com/testing-library/user-event/blob/main/README.md#keyboardtext-options - for more information about how userEvent parses your input.` -} diff --git a/src/pointer/index.ts b/src/pointer/index.ts new file mode 100644 index 00000000..ee5cd753 --- /dev/null +++ b/src/pointer/index.ts @@ -0,0 +1,102 @@ +import {getConfig as getDOMTestingLibraryConfig} from '@testing-library/dom' +import {parseKeyDef} from './parseKeyDef' +import {defaultKeyMap} from './keyMap' +import { + pointerAction, + PointerAction, + PointerActionTarget, +} from './pointerAction' +import {pointerOptions, pointerState} from './types' + +export function pointer( + input: PointerInput, + options?: Partial, +): pointerState +export function pointer( + input: PointerInput, + options: Partial< + pointerOptions & {pointerState: pointerState; delay: number} + >, +): Promise +export function pointer( + input: PointerInput, + options: Partial = {}, +) { + const {promise, state} = pointerImplementationWrapper(input, options) + + if ((options.delay ?? 0) > 0) { + return getDOMTestingLibraryConfig().asyncWrapper(() => + promise.then(() => state), + ) + } else { + // prevent users from dealing with UnhandledPromiseRejectionWarning in sync call + promise.catch(console.error) + + return state + } +} + +type PointerActionInput = + | string + | ({keys: string} & PointerActionTarget) + | PointerAction +type PointerInput = PointerActionInput | Array + +export function pointerImplementationWrapper( + input: PointerInput, + config: Partial, +) { + const { + pointerState: state = createPointerState(), + delay = 0, + pointerMap = defaultKeyMap, + } = config + const options = { + delay, + pointerMap, + } + + const actions: PointerAction[] = [] + ;(Array.isArray(input) ? input : [input]).forEach(actionInput => { + if (typeof actionInput === 'string') { + actions.push(...parseKeyDef(actionInput, options)) + } else if ('keys' in actionInput) { + actions.push( + ...parseKeyDef(actionInput.keys, options).map(i => ({ + ...actionInput, + ...i, + })), + ) + } else { + actions.push(actionInput) + } + }) + + return { + promise: pointerAction(actions, options, state), + state, + } +} + +export function createPointerState(): pointerState { + return { + pointerId: 1, + position: { + mouse: { + pointerType: 'mouse', + pointerId: 1, + coords: { + clientX: 0, + clientY: 0, + offsetX: 0, + offsetY: 0, + pageX: 0, + pageY: 0, + x: 0, + y: 0, + }, + }, + }, + pressed: [], + } +} diff --git a/src/pointer/keyMap.ts b/src/pointer/keyMap.ts new file mode 100644 index 00000000..e194dea4 --- /dev/null +++ b/src/pointer/keyMap.ts @@ -0,0 +1,10 @@ +import {pointerKey} from './types' + +export const defaultKeyMap: pointerKey[] = [ + {name: 'MouseLeft', pointerType: 'mouse', button: 'primary'}, + {name: 'MouseRight', pointerType: 'mouse', button: 'secondary'}, + {name: 'MouseMiddle', pointerType: 'mouse', button: 'auxiliary'}, + {name: 'TouchA', pointerType: 'touch'}, + {name: 'TouchB', pointerType: 'touch'}, + {name: 'TouchC', pointerType: 'touch'}, +] diff --git a/src/pointer/parseKeyDef.ts b/src/pointer/parseKeyDef.ts new file mode 100644 index 00000000..e2fe70ae --- /dev/null +++ b/src/pointer/parseKeyDef.ts @@ -0,0 +1,28 @@ +import {readNextDescriptor} from '../utils' +import {pointerKey, pointerOptions} from './types' + +export function parseKeyDef(keys: string, {pointerMap}: pointerOptions) { + const defs: Array<{ + keyDef: pointerKey + releasePrevious: boolean + releaseSelf: boolean + }> = [] + + do { + const { + descriptor, + consumedLength, + releasePrevious, + releaseSelf = true, + } = readNextDescriptor(keys) + const keyDef = pointerMap.find(p => p.name === descriptor) + + if (keyDef) { + defs.push({keyDef, releasePrevious, releaseSelf}) + } + + keys = keys.slice(consumedLength) + } while (keys) + + return defs +} diff --git a/src/pointer/pointerAction.ts b/src/pointer/pointerAction.ts new file mode 100644 index 00000000..9af8dc5a --- /dev/null +++ b/src/pointer/pointerAction.ts @@ -0,0 +1,84 @@ +import {Coords, wait} from '../utils' +import {pointerMove, PointerMoveAction} from './pointerMove' +import {pointerPress, PointerPressAction} from './pointerPress' +import {pointerOptions, pointerState} from './types' + +export type PointerActionTarget = { + target?: Element + coords?: Partial +} + +export type PointerAction = PointerActionTarget & + ( + | Omit + | Omit + ) + +export async function pointerAction( + actions: PointerAction[], + options: pointerOptions, + state: pointerState, +): Promise { + const ret: Array> = [] + + 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, state) + const coords = completeCoords({ + ...(pointerName in state.position + ? state.position[pointerName].coords + : undefined), + ...action.coords, + }) + + const promise = + 'keyDef' in action + ? pointerPress({...action, target, coords}, state) + : pointerMove({...action, target, coords}, state) + + ret.push(promise) + + if (options.delay > 0) { + await promise + if (i < actions.length - 1) { + await wait(options.delay) + } + } + } + + delete state.activeClickCount + + return Promise.all(ret) +} + +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 +} + +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 new file mode 100644 index 00000000..3ecd32f7 --- /dev/null +++ b/src/pointer/pointerMove.ts @@ -0,0 +1,82 @@ +import {Coords, firePointerEvent, isDescendantOrSelf} from '../utils' +import {pointerState, PointerTarget} from './types' + +export interface PointerMoveAction extends PointerTarget { + pointerName?: string +} + +export async function pointerMove( + {pointerName = 'mouse', target, coords}: PointerMoveAction, + state: pointerState, +): Promise { + if (!(pointerName in state.position)) { + throw new Error( + `Trying to move pointer "${pointerName}" which does not exist.`, + ) + } + + const { + pointerId, + pointerType, + target: prevTarget, + coords: prevCoords, + } = state.position[pointerName] + + if (prevTarget && prevTarget !== target) { + // Here we could probably calculate a few coords to a fake boundary(?) + fireMove(prevTarget, prevCoords) + + if (!isDescendantOrSelf(target, prevTarget)) { + fireLeave(prevTarget, prevCoords) + } + } + + 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) + + state.position[pointerName] = {pointerId, pointerType, target, coords} + + function fireMove(eventTarget: Element, eventCoords: Coords) { + fire(eventTarget, 'pointermove', eventCoords) + if (pointerType === 'mouse') { + fire(eventTarget, 'mousemove', eventCoords) + } + } + + function fireLeave(eventTarget: Element, eventCoords: Coords) { + fire(eventTarget, 'pointerout', eventCoords) + fire(eventTarget, 'pointerleave', eventCoords) + if (pointerType === 'mouse') { + fire(eventTarget, 'mouseout', eventCoords) + fire(eventTarget, 'mouseleave', eventCoords) + } + } + + function fireEnter(eventTarget: Element, eventCoords: Coords) { + fire(eventTarget, 'pointerover', eventCoords) + fire(eventTarget, 'pointerenter', eventCoords) + if (pointerType === 'mouse') { + fire(eventTarget, 'mouseover', eventCoords) + fire(eventTarget, 'mouseenter', eventCoords) + } + } + + function fire(eventTarget: Element, type: string, eventCoords: Coords) { + return firePointerEvent(eventTarget, type, { + buttons: state.pressed + .filter(p => p.keyDef.pointerType === pointerType) + .map(p => p.keyDef.button ?? 0), + coords: eventCoords, + pointerId, + pointerType, + }) + } +} diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts new file mode 100644 index 00000000..d3421bec --- /dev/null +++ b/src/pointer/pointerPress.ts @@ -0,0 +1,183 @@ +import {Coords, firePointerEvent} from '../utils' +import type {pointerKey, pointerState, PointerTarget} from './types' + +export interface PointerPressAction extends PointerTarget { + keyDef: pointerKey + releasePrevious: boolean + releaseSelf: boolean +} + +export async function pointerPress( + {keyDef, releasePrevious, releaseSelf, target, coords}: PointerPressAction, + state: pointerState, +): Promise { + const previous = state.pressed.find(p => p.keyDef === keyDef) + + const pointerName = + keyDef.pointerType === 'touch' ? keyDef.name : keyDef.pointerType + + if (previous) { + up(pointerName, keyDef, target, coords, state, previous) + } + + if (!releasePrevious) { + const press = down(pointerName, keyDef, target, coords, state) + + if (releaseSelf) { + up(pointerName, keyDef, target, coords, state, press) + } + } +} + +function getNextPointerId(state: pointerState) { + state.pointerId = state.pointerId + 1 + return state.pointerId +} + +function down( + pointerName: string, + keyDef: pointerKey, + target: Element, + coords: Coords, + state: pointerState, +) { + const {name, pointerType, button} = keyDef + const pointerId = pointerType === 'mouse' ? 1 : getNextPointerId(state) + + state.position[pointerName] = { + pointerId, + pointerType, + target, + coords, + } + + let isMultiTouch = false + let isPrimary = true + if (pointerType !== 'mouse') { + for (const obj of state.pressed) { + // TODO: test multi device input across browsers + // istanbul ignore else + if (obj.keyDef.pointerType === pointerType) { + obj.isMultiTouch = true + isMultiTouch = true + isPrimary = false + } + } + } + + if (state.activeClickCount?.[0] !== name) { + delete state.activeClickCount + } + const clickCount = Number(state.activeClickCount?.[1] ?? 0) + 1 + state.activeClickCount = [name, clickCount] + + const pressObj = { + keyDef, + downTarget: target, + pointerId, + unpreventedDefault: true, + isMultiTouch, + isPrimary, + clickCount, + } + state.pressed.push(pressObj) + + if (pointerType !== 'mouse') { + fire('pointerover') + fire('pointerenter') + } + if ( + pointerType !== 'mouse' || + !state.pressed.some( + p => p.keyDef !== keyDef && p.keyDef.pointerType === pointerType, + ) + ) { + fire('pointerdown') + } + if (pointerType === 'mouse') { + pressObj.unpreventedDefault = fire('mousedown') + } + + // TODO: touch... + + return pressObj + + function fire(type: string) { + return firePointerEvent(target, type, { + button, + buttons: state.pressed + .filter(p => p.keyDef.pointerType === pointerType) + .map(p => p.keyDef.button ?? 0), + clickCount, + coords, + isPrimary, + pointerId, + pointerType, + }) + } +} + +function up( + pointerName: string, + {pointerType, button}: pointerKey, + target: Element, + coords: Coords, + state: pointerState, + pressed: pointerState['pressed'][number], +) { + state.pressed = state.pressed.filter(p => p !== pressed) + + const {isMultiTouch, isPrimary, pointerId, clickCount} = pressed + let {unpreventedDefault} = pressed + + state.position[pointerName] = { + pointerId, + pointerType, + target, + coords, + } + + // TODO: pointerleave for touch device + + if ( + pointerType !== 'mouse' || + !state.pressed.filter(p => p.keyDef.pointerType === pointerType).length + ) { + fire('pointerup') + } + if (pointerType !== 'mouse') { + fire('pointerout') + fire('pointerleave') + } + if (pointerType !== 'mouse' && !isMultiTouch) { + if (clickCount === 1) { + fire('mouseover') + fire('mouseenter') + } + fire('mousemove') + unpreventedDefault = fire('mousedown') && unpreventedDefault + } + + if (pointerType === 'mouse' || !isMultiTouch) { + unpreventedDefault = fire('mouseup') && unpreventedDefault + + const canClick = pointerType !== 'mouse' || button === 'primary' + if (canClick && unpreventedDefault && target === pressed.downTarget) { + fire('click') + } + } + + function fire(type: string) { + return firePointerEvent(target, type, { + button, + buttons: state.pressed + .filter(p => p.keyDef.pointerType === pointerType) + .map(p => p.keyDef.button ?? 0), + clickCount, + coords, + isPrimary, + pointerId, + pointerType, + }) + } +} diff --git a/src/pointer/types.ts b/src/pointer/types.ts new file mode 100644 index 00000000..ddf52725 --- /dev/null +++ b/src/pointer/types.ts @@ -0,0 +1,63 @@ +import {Coords, 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' + target?: Element + coords: Coords + } + > + + /** + * Last applied pointer id + */ + pointerId: number +} + +export type pointerOptions = { + /** Delay between keystrokes */ + delay: number + /** Available pointer keys */ + pointerMap: pointerKey[] +} + +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: Coords +} diff --git a/src/setup.ts b/src/setup.ts index b89eaea5..29d712b0 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,6 +4,8 @@ import {hover, unhover} from 'hover' import {createKeyboardState, keyboard, keyboardOptions} from 'keyboard' import type {keyboardState} from 'keyboard/types' import {paste} from 'paste' +import {createPointerState, pointer} from 'pointer' +import type {pointerOptions, pointerState} from 'pointer/types' import {deselectOptions, selectOptions} from 'select-options' import {tab, tabOptions} from 'tab' import {type} from 'type' @@ -19,6 +21,7 @@ export const userEventApis = { hover, keyboard, paste, + pointer, selectOptions, tab, type, @@ -31,6 +34,8 @@ type ClickOptions = Omit type KeyboardOptions = Partial +type PointerApiOptions = Partial + type TabOptions = Omit type TypeOptions = Omit< @@ -44,6 +49,7 @@ interface SetupOptions extends ClickOptions, KeyboardOptions, PointerOptions, + PointerApiOptions, TabOptions, TypeOptions, UploadOptions {} @@ -57,6 +63,7 @@ export function setup(options: SetupOptions = {}) { return _setup(options, { keyboardState: createKeyboardState(), + pointerState: createPointerState(), }) } @@ -64,10 +71,11 @@ function _setup( { applyAccept, autoModify, - delay, + delay = 0, document, focusTrap, keyboardMap, + pointerMap, skipAutoClose, skipClick, skipHover, @@ -75,8 +83,10 @@ function _setup( }: SetupOptions, { keyboardState, + pointerState, }: { keyboardState: keyboardState + pointerState: pointerState }, ): UserEventApis & { /** @@ -93,6 +103,10 @@ function _setup( const pointerDefaults: PointerOptions = { skipPointerEventsCheck, } + const pointerApiDefaults: PointerApiOptions = { + delay, + pointerMap, + } const clickDefaults: clickOptions = { skipHover, } @@ -144,6 +158,15 @@ function _setup( return paste(...args) }, + // pointer needs typecasting because of the overloading + pointer: ((...args: Parameters) => { + args[1] = {...pointerApiDefaults, ...args[1], pointerState} + const ret = pointer(...args) as pointerState | Promise + if (ret instanceof Promise) { + return ret.then(() => undefined) + } + }) as typeof pointer, + selectOptions: (...args: Parameters) => { args[3] = {...pointerDefaults, ...args[3]} return selectOptions(...args) @@ -159,6 +182,7 @@ function _setup( }, { keyboardState, + pointerState, }, ) }, diff --git a/src/utils/index.ts b/src/utils/index.ts index 54c76f30..d552ba2c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -17,7 +17,10 @@ export * from './focus/getActiveElement' export * from './focus/isFocusable' export * from './focus/selector' +export * from './keyDef/readNextDescriptor' + export * from './misc/eventWrapper' +export * from './misc/isDescendantOrSelf' export * from './misc/isElementType' export * from './misc/isLabelWithInternallyDisabledControl' export * from './misc/isVisible' @@ -26,3 +29,7 @@ export * from './misc/isDocument' export * from './misc/wait' export * from './misc/hasPointerEvents' export * from './misc/hasFormSubmit' + +export * from './pointer/fakeEvent' +export * from './pointer/firePointerEvents' +export * from './pointer/mouseButtons' diff --git a/src/utils/keyDef/readNextDescriptor.ts b/src/utils/keyDef/readNextDescriptor.ts new file mode 100644 index 00000000..713b5431 --- /dev/null +++ b/src/utils/keyDef/readNextDescriptor.ts @@ -0,0 +1,140 @@ +enum bracketDict { + '{' = '}', + '[' = ']', +} + +/** + * Read the next key definition from user input + * + * Describe key per `{descriptor}` or `[descriptor]`. + * Everything else will be interpreted as a single character as descriptor - e.g. `a`. + * Brackets `{` and `[` can be escaped by doubling - e.g. `foo[[bar` translates to `foo[bar`. + * A previously pressed key can be released per `{/descriptor}`. + * Keeping the key pressed can be written as `{descriptor>}`. + * When keeping the key pressed you can choose how long the key is pressed `{descriptor>3}`. + * You can then release the key per `{descriptor>3/}` or keep it pressed and continue with the next key. + */ +export function readNextDescriptor(text: string) { + let pos = 0 + const startBracket = + text[pos] in bracketDict ? (text[pos] as keyof typeof bracketDict) : '' + + pos += startBracket.length + + // `foo{{bar` is an escaped char at position 3, + // but `foo{{{>5}bar` should be treated as `{` pressed down for 5 keydowns. + const startBracketRepeated = startBracket + ? (text.match(new RegExp(`^\\${startBracket}+`)) as RegExpMatchArray)[0] + .length + : 0 + const isEscapedChar = + startBracketRepeated === 2 || + (startBracket === '{' && startBracketRepeated > 3) + + const type = isEscapedChar ? '' : startBracket + + return { + type, + ...(type === '' ? readPrintableChar(text, pos) : readTag(text, pos, type)), + } +} + +function readPrintableChar(text: string, pos: number) { + const descriptor = text[pos] + + assertDescriptor(descriptor, text, pos) + + pos += descriptor.length + + return { + consumedLength: pos, + descriptor, + releasePrevious: false, + releaseSelf: true, + repeat: 1, + } +} + +function readTag( + text: string, + pos: number, + startBracket: keyof typeof bracketDict, +) { + const releasePreviousModifier = text[pos] === '/' ? '/' : '' + + pos += releasePreviousModifier.length + + const descriptor = text.slice(pos).match(/^\w+/)?.[0] + + assertDescriptor(descriptor, text, pos) + + pos += descriptor.length + + const repeatModifier = text.slice(pos).match(/^>\d+/)?.[0] ?? '' + + pos += repeatModifier.length + + const releaseSelfModifier = + text[pos] === '/' || (!repeatModifier && text[pos] === '>') ? text[pos] : '' + + pos += releaseSelfModifier.length + + const expectedEndBracket = bracketDict[startBracket] + const endBracket = text[pos] === expectedEndBracket ? expectedEndBracket : '' + + if (!endBracket) { + throw new Error( + getErrorMessage( + [ + !repeatModifier && 'repeat modifier', + !releaseSelfModifier && 'release modifier', + `"${expectedEndBracket}"`, + ] + .filter(Boolean) + .join(' or '), + text[pos], + text, + ), + ) + } + + pos += endBracket.length + + return { + consumedLength: pos, + descriptor, + releasePrevious: !!releasePreviousModifier, + repeat: repeatModifier ? Math.max(Number(repeatModifier.substr(1)), 1) : 1, + releaseSelf: hasReleaseSelf(releaseSelfModifier, repeatModifier), + } +} + +function assertDescriptor( + descriptor: string | undefined, + text: string, + pos: number, +): asserts descriptor is string { + if (!descriptor) { + throw new Error(getErrorMessage('key descriptor', text[pos], text)) + } +} + +function hasReleaseSelf(releaseSelfModifier: string, repeatModifier: string) { + if (releaseSelfModifier) { + return releaseSelfModifier === '/' + } + + if (repeatModifier) { + return false + } +} + +function getErrorMessage( + expected: string, + found: string | undefined, + text: string, +) { + return `Expected ${expected} but found "${found ?? ''}" in "${text}" + See https://github.com/testing-library/user-event/blob/main/README.md#keyboardtext-options + for more information about how userEvent parses your input.` +} diff --git a/src/utils/misc/isDescendantOrSelf.ts b/src/utils/misc/isDescendantOrSelf.ts new file mode 100644 index 00000000..f6971148 --- /dev/null +++ b/src/utils/misc/isDescendantOrSelf.ts @@ -0,0 +1,15 @@ +export function isDescendantOrSelf( + potentialDescendant: Element, + potentialAncestor: Element, +) { + let el: Element | null = potentialDescendant + + do { + if (el === potentialAncestor) { + return true + } + el = el.parentElement + } while (el) + + return false +} diff --git a/src/utils/pointer/fakeEvent.ts b/src/utils/pointer/fakeEvent.ts new file mode 100644 index 00000000..71e5b815 --- /dev/null +++ b/src/utils/pointer/fakeEvent.ts @@ -0,0 +1,72 @@ +// See : https://github.com/testing-library/react-testing-library/issues/268 + +export interface FakeEventInit extends MouseEventInit, PointerEventInit { + x?: number + y?: number + clientX?: number + clientY?: number + offsetX?: number + offsetY?: number + pageX?: number + pageY?: number +} + +function assignProps(obj: MouseEvent | PointerEvent, props: FakeEventInit) { + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(obj, key, {get: () => value}) + } +} + +function assignPositionInit( + obj: MouseEvent | PointerEvent, + {x, y, clientX, clientY, offsetX, offsetY, pageX, pageY}: FakeEventInit, +) { + assignProps(obj, { + x, + y, + clientX, + clientY, + offsetX, + offsetY, + pageX, + pageY, + }) +} + +function assignPointerInit( + obj: MouseEvent | PointerEvent, + {isPrimary, pointerId, pointerType}: FakeEventInit, +) { + assignProps(obj, { + isPrimary, + pointerId, + pointerType, + }) +} + +const notBubbling = ['mouseover', 'mouseout', 'pointerover', 'pointerout'] + +function getInitDefaults(type: string, init: FakeEventInit): FakeEventInit { + return { + bubbles: !notBubbling.includes(type), + cancelable: true, + composed: true, + ...init, + } +} + +export class FakeMouseEvent extends MouseEvent { + constructor(type: string, init: FakeEventInit) { + super(type, getInitDefaults(type, init)) + assignPositionInit(this, init) + } +} + +// Should extend PointerEvent, but... https://github.com/jsdom/jsdom/issues/2527 +export class FakePointerEvent extends MouseEvent { + constructor(type: string, init: FakeEventInit) { + 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 new file mode 100644 index 00000000..6c2f5c8a --- /dev/null +++ b/src/utils/pointer/firePointerEvents.ts @@ -0,0 +1,62 @@ +import {fireEvent} from '@testing-library/dom' +import {FakeEventInit, FakeMouseEvent, FakePointerEvent} 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, + { + pointerType, + button, + buttons, + coords, + pointerId, + isPrimary, + clickCount, + }: { + pointerType?: 'mouse' | 'pen' | 'touch' + button?: MouseButton + buttons: MouseButton[] + coords: Coords + pointerId?: number + isPrimary?: boolean + clickCount?: number + }, +) { + const Event = + type === 'click' || type.startsWith('mouse') + ? FakeMouseEvent + : FakePointerEvent + + let init: FakeEventInit = { + ...coords, + } + if (Event === FakePointerEvent) { + init = {...init, pointerId, pointerType} + } + if (['pointerdown', 'pointerup'].includes(type)) { + init.isPrimary = isPrimary + } + if ( + ['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'click'].includes(type) + ) { + init.button = getMouseButton(button ?? 0) + init.buttons = getMouseButtons(...buttons) + } + if (['mousedown', 'mouseup', 'click'].includes(type)) { + init.detail = clickCount + } + + return fireEvent(target, new Event(type, init)) +} diff --git a/src/utils/pointer/mouseButtons.ts b/src/utils/pointer/mouseButtons.ts new file mode 100644 index 00000000..b9a84edd --- /dev/null +++ b/src/utils/pointer/mouseButtons.ts @@ -0,0 +1,25 @@ +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 getMouseButton(button: MouseButton): number { + return typeof button === 'number' ? button : MouseButton[button] +} + +export function getMouseButtons(...buttons: Array) { + let v = 0 + for (const t of buttons) { + const pos = getMouseButton(t) + // eslint-disable-next-line no-bitwise + v &= 2 ** pos + } + return v +}