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
+}