Skip to content

Commit 5dc09f0

Browse files
authored
feat(browser): introduce experimental pointer API (#10780)
1 parent f150f51 commit 5dc09f0

10 files changed

Lines changed: 1133 additions & 12 deletions

File tree

‎docs/api/browser/interactivity.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,58 @@ await page.getByRole('tablist').wheel({ direction: 'right' })
235235
This method is intended for testing UI that explicitly listens to `wheel` events (e.g., custom zoom controls, horizontal tab scrolling, canvas interactions). If you need to scroll the page to bring an element into view, rely on the built-in automatic scrolling functionality provided by other `userEvent` methods or [locator actions](/api/browser/locators#methods) instead.
236236
:::
237237

238+
## userEvent.pointer <Version type="experimental">5.1.0</Version> <Experimental /> {#userevent-pointer}
239+
240+
```ts
241+
function pointer(options: UserEventPointerInput): Promise<void>
242+
```
243+
244+
Performs one or more pointer actions. Pass a key string, an action object, or an array of strings and objects. Key strings use [user-event `pointer` syntax](https://testing-library.com/docs/user-event/pointer). For example, `[MouseLeft]` presses and releases the left mouse button, `[MouseLeft>]` keeps it pressed, and `[/MouseLeft]` releases it.
245+
246+
::: tip
247+
The `pointer` method is a low-level API for composing complex pointer interactions and performing actions that dedicated methods do not support, such as dragging an element to a precise position.
248+
249+
For one-off actions, prefer the equivalent dedicated method because in most cases they are simpler to use. For example, if you just want to click an element once, use [`userEvent.click`](#userevent-click).
250+
:::
251+
252+
An action object accepts `keys`, `target`, and `coords`. An object without `keys` only moves the pointer.
253+
254+
The pointer position persists between `userEvent.pointer` calls on the same `userEvent` instance. This means that if an action omits both `target` and `coords`, it uses the position from the previous action.
255+
256+
::: warning
257+
When the [Browser UI](/config/browser/ui) is enabled, the test iframe can be scaled to fit the available space. Vitest adjusts pointer coordinates to account for the scaling, but browser pixel rounding can cause the resulting event coordinates to differ by several _iframe pixels_. **Avoid relying on exact coordinates in this mode**.
258+
:::
259+
260+
```ts
261+
import { page, userEvent } from 'vitest/browser'
262+
263+
test('drags an element with pointer actions', async () => {
264+
const source = page.getByTestId('source')
265+
const target = page.getByTestId('target')
266+
267+
await userEvent.pointer([
268+
{ target: source, keys: '[MouseLeft>]' },
269+
{ target, coords: { x: 10, y: 10 }, keys: '[/MouseLeft]' },
270+
])
271+
})
272+
```
273+
274+
::: warning Provider support and differences from user-event
275+
This API is not consistently supported across providers, and its behavior can differ from the `user-event` library.
276+
277+
- The `playwright` provider does not support touch pointer keys such as `TouchA`.
278+
- The `webdriverio` provider does not support this method at all.
279+
- The `preview` provider simulates events using `@testing-library/user-event`.
280+
281+
Vitest does not support the `node` and `offset` fields used for text selection and only accepts `x` and `y` values in `coords`.
282+
283+
Without `target`, `coords` values are considered coordinates relative to the viewport. With `target`, they are offsets from its top-left corner. Unlike user-event, where coordinates paired with a target describe the final pointer position rather than an offset. When `coords` is provided, an omitted axis defaults to `0`.
284+
:::
285+
286+
References:
287+
288+
- [testing-library `pointer` API](https://testing-library.com/docs/user-event/pointer)
289+
238290
## userEvent.fill
239291

240292
```ts

‎packages/browser-playwright/src/commands/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dragAndDrop } from './dragAndDrop'
44
import { fill } from './fill'
55
import { hover } from './hover'
66
import { keyboard, keyboardCleanup } from './keyboard'
7+
import { pointer } from './pointer'
78
import { takeScreenshot } from './screenshot'
89
import { selectOptions } from './select'
910
import { tab } from './tab'
@@ -28,6 +29,7 @@ export default {
2829
__vitest_dblClick: dblClick as typeof dblClick,
2930
__vitest_tripleClick: tripleClick as typeof tripleClick,
3031
__vitest_wheel: wheel as typeof wheel,
32+
__vitest_pointer: pointer as typeof pointer,
3133
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
3234
__vitest_type: type as typeof type,
3335
__vitest_clear: clear as typeof clear,
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { SerializedLocator } from '@vitest/browser'
2+
import type { Locator, UserEventPointerInputNormalized } from 'vitest/browser'
3+
import type { BrowserCommandContext } from 'vitest/node'
4+
import type { UserEventCommand } from './utils'
5+
import { parseKeyDef } from '@vitest/browser'
6+
import { click } from './click'
7+
import { hover } from './hover'
8+
9+
type SerializedPointerInput = ElementToSerializedLocator<UserEventPointerInputNormalized[number]>
10+
interface PointerReturnData extends Pick<SerializedPointerInput, 'coords' | 'target'> {
11+
unreleased: string[]
12+
}
13+
type PointerEvent = (
14+
input: readonly SerializedPointerInput[],
15+
state: Partial<PointerReturnData>,
16+
) => Promise<PointerReturnData>
17+
18+
type ElementToSerializedLocator<T> = T extends Element | Locator
19+
? SerializedLocator
20+
: {
21+
[K in keyof T]: ElementToSerializedLocator<T[K]>
22+
}
23+
24+
export const pointer: UserEventCommand<PointerEvent> = async (context, input, state = {}) => {
25+
const pressedKeys = new Set<string>(state.unreleased)
26+
let lastTarget: SerializedPointerInput['target'] = state.target
27+
let lastCoords: SerializedPointerInput['coords'] = state.coords
28+
29+
for (const option of input) {
30+
let target = option.target
31+
let coords = option.coords
32+
33+
if (target || coords) {
34+
lastTarget = target
35+
lastCoords = coords
36+
} else {
37+
target = lastTarget
38+
coords = lastCoords
39+
}
40+
41+
const pointerAction = { ...option, target, coords }
42+
const keys = 'keys' in option ? option.keys : null
43+
const parsedKeys = keys === null ? null : groupKeyDefs(parseKeyDef(keys))
44+
const hasOnlyClickActions = parsedKeys?.every(
45+
({
46+
keyDef: {
47+
keyDef: { code },
48+
releasePrevious,
49+
releaseSelf,
50+
},
51+
}) => code === 'MouseLeft' && !releasePrevious && releaseSelf,
52+
)
53+
54+
// click has its own moving logic, no need to move twice
55+
if (!hasOnlyClickActions) {
56+
if (target) {
57+
await hover(context, target, {
58+
position: coords,
59+
})
60+
} else if (coords) {
61+
await context.page.mouse.move(coords.x, coords.y)
62+
}
63+
}
64+
65+
if (parsedKeys) {
66+
for (const key of parsedKeys) {
67+
await keyDefHandler(key, pointerAction, pressedKeys, context)
68+
}
69+
}
70+
}
71+
72+
return {
73+
unreleased: Array.from(pressedKeys),
74+
target: lastTarget,
75+
coords: lastCoords,
76+
}
77+
}
78+
79+
type KeyDefOutput = ReturnType<typeof parseKeyDef>[number]
80+
interface GroupedKeyDef {
81+
times: number
82+
keyDef: KeyDefOutput
83+
}
84+
85+
function groupKeyDefs(keyDefs: readonly KeyDefOutput[]): GroupedKeyDef[] {
86+
const output: GroupedKeyDef[] = []
87+
let last: GroupedKeyDef | undefined
88+
89+
for (const keyDef of keyDefs) {
90+
if (
91+
last !== undefined &&
92+
// for our purpose we can ignore the `repeat` property
93+
last.keyDef.releasePrevious === keyDef.releasePrevious &&
94+
last.keyDef.releaseSelf === keyDef.releaseSelf &&
95+
last.keyDef.keyDef.key === keyDef.keyDef.key &&
96+
last.keyDef.keyDef.code === keyDef.keyDef.code &&
97+
last.keyDef.keyDef.location === keyDef.keyDef.location
98+
) {
99+
last.times += keyDef.repeat
100+
} else {
101+
last = { times: keyDef.repeat, keyDef }
102+
output.push(last)
103+
}
104+
}
105+
106+
return output
107+
}
108+
109+
const MOUSE_KEYS = ['MouseLeft', 'MouseRight', 'MouseMiddle']
110+
111+
async function keyDefHandler(
112+
{ keyDef: { keyDef, releasePrevious, releaseSelf }, times }: GroupedKeyDef,
113+
pointerAction: Omit<SerializedPointerInput, 'keys'>,
114+
pressedKeys: Set<string>,
115+
context: BrowserCommandContext,
116+
) {
117+
const key = keyDef.key!
118+
const code = keyDef.code!
119+
120+
if (MOUSE_KEYS.includes(code)) {
121+
const button = code.replace('Mouse', '').toLowerCase() as 'left' | 'right' | 'middle'
122+
const mouseOptions = {
123+
button,
124+
clickCount: times,
125+
} satisfies Parameters<(typeof context)['page']['mouse']['up']>[0]
126+
127+
if (releasePrevious) {
128+
await context.page.mouse.up(mouseOptions)
129+
} else if (releaseSelf) {
130+
if (pointerAction.target) {
131+
await click(context, pointerAction.target, {
132+
...mouseOptions,
133+
position: pointerAction.coords,
134+
})
135+
} else {
136+
const x = pointerAction.coords?.x ?? 0
137+
const y = pointerAction.coords?.y ?? 0
138+
139+
await context.page.mouse.click(x, y, mouseOptions)
140+
}
141+
} else {
142+
await context.page.mouse.down(mouseOptions)
143+
}
144+
145+
return
146+
}
147+
148+
if (key === 'Unknown') {
149+
return
150+
}
151+
152+
if (!releasePrevious) {
153+
if (releaseSelf) {
154+
for (let count = 0; count < times; count += 1) {
155+
await context.page.keyboard.press(key)
156+
}
157+
} else {
158+
await context.page.keyboard.down(key)
159+
pressedKeys.add(key)
160+
}
161+
} else if (pressedKeys.has(key)) {
162+
await context.page.keyboard.up(key)
163+
pressedKeys.delete(key)
164+
}
165+
}

‎packages/browser-playwright/src/commands/type.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ import type { UserEventCommand } from './utils'
33
import { keyboardImplementation } from './keyboard'
44
import { getDescribedLocator } from './utils'
55

6-
export const type: UserEventCommand<UserEvent['type']> = async (
7-
context,
8-
selector,
9-
text,
10-
options = {},
11-
) => {
6+
export const type: UserEventCommand<
7+
(...args: Parameters<UserEvent['type']>) => Promise<{ unreleased: string[] }>
8+
> = async (context, selector, text, options = {}) => {
129
const { skipClick = false, skipAutoClose = false } = options
1310
const unreleased = new Set((Reflect.get(options, 'unreleased') as string[]) ?? [])
1411

‎packages/browser-playwright/src/commands/utils.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { Locator } from 'vitest/browser'
33
import type { BrowserCommand, BrowserCommandContext } from 'vitest/node'
44

55
export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand<
6-
ConvertUserEventParameters<Parameters<T>>
6+
ConvertUserEventParameters<Parameters<T>>,
7+
ReturnType<T>
78
>
89

910
type ConvertElementToLocator<T> = T extends Element | Locator ? SerializedLocator : T

‎packages/browser/context.d.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,33 @@ export interface UserEvent {
241241
* await userEvent.wheel(container, { direction: 'up', times: 5 })
242242
*/
243243
wheel(element: Element | Locator, options: UserEventWheelOptions): Promise<void>
244+
/**
245+
* Performs one or more pointer actions.
246+
*
247+
* Pass a string to press or release a pointer button, an object to position the pointer and perform an action, or an array to perform actions in order.
248+
* Key strings use the {@link https://testing-library.com/docs/user-event/pointer|`@testing-library/user-event` pointer syntax}.
249+
*
250+
* Unlike `@testing-library/user-event`, Vitest supports only `x` and `y` coordinates and does not support the `node` and `offset` selection fields.
251+
*
252+
* @param options - A pointer action or an ordered sequence of pointer actions.
253+
* @returns A promise that resolves after all pointer actions finish.
254+
*
255+
* @since 5.1.0
256+
* @experimental The Pointer API is experimental and not subject to semver.
257+
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-pointer}
258+
*
259+
* @example
260+
* // Click the left mouse button.
261+
* await userEvent.pointer({ target: button, keys: '[MouseLeft]' })
262+
*
263+
* @example
264+
* // Drag from `source` to a point inside `target`.
265+
* await userEvent.pointer([
266+
* { target: source, keys: '[MouseLeft>]' },
267+
* { target, coords: { x: 10, y: 10 }, keys: '[/MouseLeft]' },
268+
* ])
269+
*/
270+
pointer(options: UserEventPointerInput): Promise<void>
244271
/**
245272
* Choose one or more values from a select element. Uses provider's API under the hood.
246273
* If select doesn't have `multiple` attribute, only the first value will be selected.
@@ -439,6 +466,62 @@ export interface UserEventWheelDirectionOptions extends UserEventWheelBaseOption
439466
*/
440467
export type UserEventWheelOptions = UserEventWheelDeltaOptions | UserEventWheelDirectionOptions
441468

469+
/**
470+
* Input accepted by {@link UserEvent.pointer}.
471+
*
472+
* - A string presses or releases a pointer button at the current position.
473+
* - An object with `keys` positions the pointer and performs a button action.
474+
* - An object without `keys` moves the pointer.
475+
* - An array performs its actions in order.
476+
*
477+
* @since 5.1.0
478+
* @experimental
479+
*/
480+
export type UserEventPointerInput = PointerActionInput | readonly PointerActionInput[]
481+
/**
482+
* Array form of pointer input after single-entry inputs and strings actions are expanded.
483+
*
484+
* @internal
485+
*/
486+
export type UserEventPointerInputNormalized = readonly (PointerActionInputObject & {
487+
coords?: Required<PointerCoords>
488+
})[]
489+
490+
type PointerActionInput = string | PointerActionInputObject
491+
type PointerActionInputObject = PointerActionKeys | PointerMoveAction
492+
493+
interface PointerActionPosition {
494+
/**
495+
* Element to move the pointer to before performing the action.
496+
*
497+
* If `coords` is omitted, the browser provider chooses a position within the element.
498+
* If both `target` and `coords` are omitted, the action uses the last known pointer position.
499+
*/
500+
target?: Element | Locator
501+
/**
502+
* Coordinates used to position the pointer.
503+
*
504+
* Without `target`, the values are viewport coordinates.
505+
* With `target`, the values are pixel offsets from the target's top-left corner.
506+
*
507+
* This differs from `@testing-library/user-event`, where coordinates describe the final pointer position.
508+
*
509+
* Vitest also only supports `x` and `y`. Other coordinates, such as `clientX`, `offsetX`, `pageX`, and `screenX`, are not supported.
510+
*/
511+
coords?: PointerCoords
512+
}
513+
514+
interface PointerActionKeys extends PointerActionPosition {
515+
keys: string
516+
}
517+
518+
interface PointerMoveAction extends PointerActionPosition {}
519+
520+
interface PointerCoords {
521+
x?: number
522+
y?: number
523+
}
524+
442525
export interface LocatorOptions {
443526
/**
444527
* Whether to find an exact match: case-sensitive and whole-string. Defaults to `browser.locators.exact`, which is

0 commit comments

Comments
 (0)