Skip to content

Commit d786dca

Browse files
committed
feat(browser): add userEvent.wheel API
1 parent 372e86f commit d786dca

14 files changed

Lines changed: 408 additions & 5 deletions

File tree

docs/api/browser/interactivity.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,54 @@ References:
162162
- [WebdriverIO `browser.action` API](https://webdriver.io/docs/api/browser/action/): implemented via actions api with `move` plus three `down + up + pause` events in a row
163163
- [testing-library `tripleClick` API](https://testing-library.com/docs/user-event/convenience/#tripleClick)
164164

165+
## userEvent.wheel
166+
167+
```ts
168+
function wheel(
169+
element: Element | Locator,
170+
options: UserEventWheelOptions,
171+
): Promise<void>
172+
```
173+
174+
Triggers a wheel event on an element. This is useful for testing any UI that responds to wheel events.
175+
176+
You can specify the scroll amount using either `delta` for precise pixel-based control, or `direction` for simpler directional scrolling (`up`, `down`, `left`, `right`). When you need to trigger multiple wheel events, use the `times` option rather than calling the method multiple times.
177+
178+
```ts
179+
import { page, userEvent } from 'vitest/browser'
180+
181+
test('scroll using delta values', async () => {
182+
const tablist = page.getByRole('tablist')
183+
184+
// Scroll right by 100 pixels
185+
await userEvent.wheel(tablist, { delta: { x: 100 } })
186+
187+
// Scroll down by 50 pixels
188+
await userEvent.wheel(tablist, { delta: { y: 50 } })
189+
190+
// Scroll diagonally 2 times
191+
await userEvent.wheel(tablist, { delta: { x: 50, y: 100 }, times: 2 })
192+
})
193+
194+
test('scroll using direction', async () => {
195+
const tablist = page.getByRole('tablist')
196+
197+
// Scroll right 5 times
198+
await userEvent.wheel(tablist, { direction: 'right', times: 5 })
199+
200+
// Scroll left once
201+
await userEvent.wheel(tablist, { direction: 'left' })
202+
})
203+
```
204+
205+
Wheel events can also be triggered directly from locators:
206+
207+
```ts
208+
import { page } from 'vitest/browser'
209+
210+
await page.getByRole('tablist').wheel({ direction: 'right' })
211+
```
212+
165213
## userEvent.fill
166214

167215
```ts

docs/api/browser/locators.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,23 @@ await page.getByRole('img', { name: 'Rose' }).tripleClick()
648648

649649
- [See more at `userEvent.tripleClick`](/api/browser/interactivity#userevent-tripleclick)
650650

651+
### wheel
652+
653+
```ts
654+
function wheel(options: UserEventWheelOptions): Promise<void>
655+
```
656+
657+
Triggers a wheel event on an element. You can use the options to choose a general scroll `direction` or a precise `delta` value.
658+
659+
```ts
660+
import { page } from 'vitest/browser'
661+
662+
// Scroll right
663+
await page.getByRole('tablist').wheel({ direction: 'right' })
664+
```
665+
666+
- [See more at `userEvent.wheel`](/api/browser/interactivity#userevent-wheel)
667+
651668
### clear
652669

653670
```ts

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
} from './trace'
1717
import { type } from './type'
1818
import { upload } from './upload'
19+
import { wheel } from './wheel'
1920

2021
export default {
2122
__vitest_upload: upload as typeof upload,
2223
__vitest_click: click as typeof click,
2324
__vitest_dblClick: dblClick as typeof dblClick,
2425
__vitest_tripleClick: tripleClick as typeof tripleClick,
26+
__vitest_wheel: wheel as typeof wheel,
2527
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
2628
__vitest_type: type as typeof type,
2729
__vitest_clear: clear as typeof clear,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Locator, UserEventWheelOptionsWithDelta } from 'vitest/browser'
2+
import type { UserEventCommand } from './utils'
3+
import { hover } from './hover'
4+
5+
type WheelCommand = (element: Locator | Element, options: UserEventWheelOptionsWithDelta) => Promise<void>
6+
7+
export const wheel: UserEventCommand<WheelCommand> = async (
8+
context,
9+
selector,
10+
options,
11+
) => {
12+
await hover(context, selector)
13+
14+
const times = options.times ?? 1
15+
const deltaX = options.delta.x ?? 0
16+
const deltaY = options.delta.y ?? 0
17+
18+
for (let count = 0; count < times; count += 1) {
19+
await context.page.mouse.wheel(deltaX, deltaY)
20+
}
21+
}

packages/browser-webdriverio/src/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import { tab } from './tab'
1010
import { type } from './type'
1111
import { upload } from './upload'
1212
import { viewport } from './viewport'
13+
import { wheel } from './wheel'
1314

1415
export default {
1516
__vitest_upload: upload as typeof upload,
1617
__vitest_click: click as typeof click,
1718
__vitest_dblClick: dblClick as typeof dblClick,
1819
__vitest_tripleClick: tripleClick as typeof tripleClick,
20+
__vitest_wheel: wheel as typeof wheel,
1921
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
2022
__vitest_type: type as typeof type,
2123
__vitest_clear: clear as typeof clear,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Locator, UserEventWheelOptionsWithDelta } from 'vitest/browser'
2+
import type { UserEventCommand } from './utils'
3+
4+
type WheelCommand = (element: Locator | Element, options: UserEventWheelOptionsWithDelta) => Promise<void>
5+
6+
export const wheel: UserEventCommand<WheelCommand> = async (
7+
context,
8+
selector,
9+
options,
10+
) => {
11+
const browser = context.browser
12+
const times = options.times ?? 1
13+
const deltaX = options.delta.x ?? 0
14+
const deltaY = options.delta.y ?? 0
15+
16+
let action = browser.action('wheel')
17+
const wheelOptions: Parameters<typeof action['scroll']>[0] = {
18+
deltaX,
19+
deltaY,
20+
origin: browser.$(selector),
21+
}
22+
23+
for (let count = 0; count < times; count += 1) {
24+
action = action.scroll(wheelOptions)
25+
}
26+
27+
await action.perform()
28+
}

packages/browser/context.d.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SerializedConfig } from 'vitest'
2-
import { StringifyOptions, BrowserCommands } from 'vitest/internal/browser'
2+
import { AtLeastOneOf, ExactlyOneOf, StringifyOptions, BrowserCommands } from 'vitest/internal/browser'
33
import { ARIARole } from './aria-role.js'
44
import {} from './matchers.js'
55

@@ -207,6 +207,24 @@ export interface UserEvent {
207207
* @see {@link https://testing-library.com/docs/user-event/convenience/#tripleclick} testing-library API
208208
*/
209209
tripleClick: (element: Element | Locator, options?: UserEventTripleClickOptions) => Promise<void>
210+
/**
211+
* Triggers a wheel event on an element.
212+
*
213+
* @param element - The target element to receive wheel events.
214+
* @param options - Scroll configuration using `delta` or `direction`.
215+
* @returns A promise that resolves when all wheel events have been dispatched.
216+
*
217+
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
218+
*
219+
* @example
220+
* // Scroll down by 100 pixels
221+
* await userEvent.wheel(container, { delta: { y: 100 } })
222+
*
223+
* @example
224+
* // Scroll up 5 times
225+
* await userEvent.wheel(container, { direction: 'up', times: 5 })
226+
*/
227+
wheel(element: Element | Locator, options: UserEventWheelOptions): Promise<void>
210228
/**
211229
* Choose one or more values from a select element. Uses provider's API under the hood.
212230
* If select doesn't have `multiple` attribute, only the first value will be selected.
@@ -344,6 +362,35 @@ export interface UserEventDoubleClickOptions {}
344362
export interface UserEventTripleClickOptions {}
345363
export interface UserEventDragAndDropOptions {}
346364
export interface UserEventUploadOptions {}
365+
/**
366+
* Options for triggering wheel events.
367+
*
368+
* Specify scrolling using either `delta` for precise pixel values, or `direction` for semantic scrolling. These are mutually exclusive.
369+
*/
370+
export type UserEventWheelOptions = ExactlyOneOf<
371+
{
372+
/**
373+
* Precise scroll delta values in pixels. At least one axis must be specified.
374+
*
375+
* - Positive `y` scrolls down, negative `y` scrolls up.
376+
* - Positive `x` scrolls right, negative `x` scrolls left.
377+
*/
378+
delta: AtLeastOneOf<{ x: number, y: number }>,
379+
/**
380+
* Semantic scroll direction. Use this for readable tests when exact pixel values don't matter.
381+
*/
382+
direction: 'up' | 'down' | 'left' | 'right',
383+
/**
384+
* Number of wheel events to fire. Defaults to `1`.
385+
*
386+
* Useful for triggering multiple scroll steps in a single call.
387+
*/
388+
times?: number,
389+
},
390+
'delta' | 'direction'
391+
>
392+
393+
export type UserEventWheelOptionsWithDelta = Extract<UserEventWheelOptions, { delta: { x?: number; y?: number } }>
347394

348395
export interface LocatorOptions {
349396
/**
@@ -489,6 +536,24 @@ export interface Locator extends LocatorSelectors {
489536
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-tripleclick}
490537
*/
491538
tripleClick(options?: UserEventTripleClickOptions): Promise<void>
539+
/**
540+
* Triggers a wheel event on an element.
541+
*
542+
* @param element - The target element to receive wheel events.
543+
* @param options - Scroll configuration using `delta` or `direction`.
544+
* @returns A promise that resolves when all wheel events have been dispatched.
545+
*
546+
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
547+
*
548+
* @example
549+
* // Scroll down by 100 pixels
550+
* await container.wheel({ delta: { y: 100 } })
551+
*
552+
* @example
553+
* // Scroll up 5 times
554+
* await container.wheel({ direction: 'up', times: 5 })
555+
*/
556+
wheel(options: UserEventWheelOptions): Promise<void>
492557
/**
493558
* Clears the input element content
494559
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-clear}

packages/browser/src/client/tester/context.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import type {
99
Locator,
1010
LocatorSelectors,
1111
UserEvent,
12+
UserEventWheelOptions,
1213
} from 'vitest/browser'
1314
import type { StringifyOptions } from 'vitest/internal/browser'
1415
import type { IframeViewportEvent } from '../client'
1516
import type { BrowserRunnerState } from '../utils'
1617
import type { Locator as LocatorAPI } from './locators/index'
1718
import { __INTERNAL, stringify } from 'vitest/internal/browser'
1819
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
19-
import { convertToSelector, processTimeoutOptions } from './tester-utils'
20+
import { convertToSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from './tester-utils'
2021

2122
// this file should not import anything directly, only types and utils
2223

@@ -69,6 +70,9 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
6970
tripleClick(element, options) {
7071
return convertToLocator(element).tripleClick(options)
7172
},
73+
wheel(elementOrOptions: Element | Locator, options: UserEventWheelOptions) {
74+
return convertToLocator(elementOrOptions).wheel(options)
75+
},
7276
selectOptions(element, value, options) {
7377
return convertToLocator(element).selectOptions(value, options)
7478
},
@@ -230,6 +234,31 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
230234
async paste() {
231235
await userEvent.paste(clipboardData)
232236
},
237+
async wheel(element: Element | Locator, options: UserEventWheelOptions) {
238+
const resolvedElement = isLocator(element) ? element.element() : element
239+
const resolvedOptions = resolveUserEventWheelOptions(options)
240+
241+
const rect = resolvedElement.getBoundingClientRect()
242+
243+
const centerX = rect.left + rect.width / 2
244+
const centerY = rect.top + rect.height / 2
245+
246+
const wheelEvent = new WheelEvent('wheel', {
247+
clientX: centerX,
248+
clientY: centerY,
249+
deltaY: resolvedOptions.delta.y ?? 0,
250+
deltaX: resolvedOptions.delta.x ?? 0,
251+
deltaMode: 0,
252+
bubbles: true,
253+
cancelable: true,
254+
})
255+
256+
const times = options.times ?? 1
257+
258+
for (let count = 0; count < times; count += 1) {
259+
resolvedElement.dispatchEvent(wheelEvent)
260+
}
261+
},
233262
}
234263

235264
for (const [name, fn] of Object.entries(vitestUserEvent)) {

packages/browser/src/client/tester/locators/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
UserEventHoverOptions,
1111
UserEventSelectOptions,
1212
UserEventUploadOptions,
13+
UserEventWheelOptions,
1314
} from 'vitest/browser'
1415
import {
1516
asLocator,
@@ -25,7 +26,7 @@ import {
2526
import { page, server, utils } from 'vitest/browser'
2627
import { __INTERNAL } from 'vitest/internal/browser'
2728
import { ensureAwaited, getBrowserState } from '../../utils'
28-
import { escapeForTextSelector, isLocator } from '../tester-utils'
29+
import { escapeForTextSelector, isLocator, resolveUserEventWheelOptions } from '../tester-utils'
2930

3031
export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils'
3132
export {
@@ -86,6 +87,23 @@ export abstract class Locator {
8687
return this.triggerCommand<void>('__vitest_tripleClick', this.selector, options)
8788
}
8889

90+
public wheel(options: UserEventWheelOptions): Promise<void> {
91+
return ensureAwaited<void>(async () => {
92+
await this.triggerCommand<void>('__vitest_wheel', this.selector, resolveUserEventWheelOptions(options))
93+
94+
const browser = getBrowserState().config.browser.name
95+
96+
// looks like on Chromium the scroll event gets dispatched a frame later
97+
if (browser === 'chromium' || browser === 'chrome') {
98+
return new Promise((resolve) => {
99+
requestAnimationFrame(() => {
100+
resolve()
101+
})
102+
})
103+
}
104+
})
105+
}
106+
89107
public clear(options?: UserEventClearOptions): Promise<void> {
90108
return this.triggerCommand<void>('__vitest_clear', this.selector, options)
91109
}

0 commit comments

Comments
 (0)