Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/api/browser/interactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,58 @@ References:
- [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
- [testing-library `tripleClick` API](https://testing-library.com/docs/user-event/convenience/#tripleClick)

## userEvent.wheel <Version>4.1.0</Version> {#userevent-wheel}

```ts
function wheel(
element: Element | Locator,
options: UserEventWheelOptions,
): Promise<void>
```

Triggers a [`wheel` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event) on an element.

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 for better performance.

```ts
import { page, userEvent } from 'vitest/browser'

test('scroll using delta values', async () => {
const tablist = page.getByRole('tablist')

// Scroll right by 100 pixels
await userEvent.wheel(tablist, { delta: { x: 100 } })

// Scroll down by 50 pixels
await userEvent.wheel(tablist, { delta: { y: 50 } })

// Scroll diagonally 2 times
await userEvent.wheel(tablist, { delta: { x: 50, y: 100 }, times: 2 })
})

test('scroll using direction', async () => {
const tablist = page.getByRole('tablist')

// Scroll right 5 times
await userEvent.wheel(tablist, { direction: 'right', times: 5 })

// Scroll left once
await userEvent.wheel(tablist, { direction: 'left' })
})
```

Wheel events can also be triggered directly from [locators](/api/browser/locators#wheel):

```ts
import { page } from 'vitest/browser'

await page.getByRole('tablist').wheel({ direction: 'right' })
```

::: warning
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.
:::

## userEvent.fill

```ts
Expand Down
17 changes: 17 additions & 0 deletions docs/api/browser/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,23 @@ await page.getByRole('img', { name: 'Rose' }).tripleClick()

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

### wheel <Version>4.1.0</Version> {#wheel}

```ts
function wheel(options: UserEventWheelOptions): Promise<void>
```

Triggers a [`wheel` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event) on an element. You can use the options to choose a general scroll `direction` or a precise `delta` value.

```ts
import { page } from 'vitest/browser'

// Scroll right
await page.getByRole('tablist').wheel({ direction: 'right' })
```

- [See more at `userEvent.wheel`](/api/browser/interactivity#userevent-wheel)

### clear

```ts
Expand Down
2 changes: 2 additions & 0 deletions packages/browser-playwright/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import {
} from './trace'
import { type } from './type'
import { upload } from './upload'
import { wheel } from './wheel'

export default {
__vitest_upload: upload as typeof upload,
__vitest_click: click as typeof click,
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
__vitest_wheel: wheel as typeof wheel,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
Expand Down
21 changes: 21 additions & 0 deletions packages/browser-playwright/src/commands/wheel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Locator, UserEventWheelDeltaOptions } from 'vitest/browser'
import type { UserEventCommand } from './utils'
import { hover } from './hover'

type WheelCommand = (element: Locator | Element, options: UserEventWheelDeltaOptions) => Promise<void>

export const wheel: UserEventCommand<WheelCommand> = async (
context,
selector,
options,
) => {
await hover(context, selector)

const times = options.times ?? 1
const deltaX = options.delta.x ?? 0
const deltaY = options.delta.y ?? 0

for (let count = 0; count < times; count += 1) {
await context.page.mouse.wheel(deltaX, deltaY)
}
}
2 changes: 2 additions & 0 deletions packages/browser-webdriverio/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { tab } from './tab'
import { type } from './type'
import { upload } from './upload'
import { viewport } from './viewport'
import { wheel } from './wheel'

export default {
__vitest_upload: upload as typeof upload,
__vitest_click: click as typeof click,
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
__vitest_wheel: wheel as typeof wheel,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
Expand Down
28 changes: 28 additions & 0 deletions packages/browser-webdriverio/src/commands/wheel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Locator, UserEventWheelDeltaOptions } from 'vitest/browser'
import type { UserEventCommand } from './utils'

type WheelCommand = (element: Locator | Element, options: UserEventWheelDeltaOptions) => Promise<void>

export const wheel: UserEventCommand<WheelCommand> = async (
context,
selector,
options,
) => {
const browser = context.browser
const times = options.times ?? 1
const deltaX = options.delta.x ?? 0
const deltaY = options.delta.y ?? 0

let action = browser.action('wheel')
const wheelOptions: Parameters<typeof action['scroll']>[0] = {
deltaX,
deltaY,
origin: browser.$(selector),
}

for (let count = 0; count < times; count += 1) {
action = action.scroll(wheelOptions)
}

await action.perform()
}
89 changes: 89 additions & 0 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,25 @@ export interface UserEvent {
* @see {@link https://testing-library.com/docs/user-event/convenience/#tripleclick} testing-library API
*/
tripleClick: (element: Element | Locator, options?: UserEventTripleClickOptions) => Promise<void>
/**
* Triggers a {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event|`wheel` event} on an element.
*
* @param element - The target element to receive wheel events.
* @param options - Scroll configuration using `delta` or `direction`.
* @returns A promise that resolves when all wheel events have been dispatched.
*
* @since 4.1.0
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
*
* @example
* // Scroll down by 100 pixels
* await userEvent.wheel(container, { delta: { y: 100 } })
*
* @example
* // Scroll up 5 times
* await userEvent.wheel(container, { direction: 'up', times: 5 })
*/
wheel(element: Element | Locator, options: UserEventWheelOptions): Promise<void>
/**
* Choose one or more values from a select element. Uses provider's API under the hood.
* If select doesn't have `multiple` attribute, only the first value will be selected.
Expand Down Expand Up @@ -345,6 +364,58 @@ export interface UserEventTripleClickOptions {}
export interface UserEventDragAndDropOptions {}
export interface UserEventUploadOptions {}

/**
* Base options shared by all wheel event configurations.
*
* @since 4.1.0
*/
export interface UserEventWheelBaseOptions {
/**
* Number of wheel events to fire. Defaults to `1`.
*
* Useful for triggering multiple scroll steps in a single call.
*/
times?: number
}

/**
* Wheel options using pixel-based `delta` values for precise scroll control.
*
* @since 4.1.0
*/
export interface UserEventWheelDeltaOptions extends UserEventWheelBaseOptions {
/**
* Precise scroll delta values in pixels. At least one axis must be specified.
*
* - Positive `y` scrolls down, negative `y` scrolls up.
* - Positive `x` scrolls right, negative `x` scrolls left.
*/
delta: { x: number; y?: number } | { x?: number; y: number }
direction?: undefined
}

/**
* Wheel options using semantic `direction` values for simpler scroll control.
*
* @since 4.1.0
*/
export interface UserEventWheelDirectionOptions extends UserEventWheelBaseOptions {
/**
* Semantic scroll direction. Use this for readable tests when exact pixel values don't matter.
*/
direction: 'up' | 'down' | 'left' | 'right'
delta?: undefined
}

/**
* Options for triggering wheel events.
*
* Specify scrolling using either `delta` for precise pixel values, or `direction` for semantic scrolling. These are mutually exclusive.
*
* @since 4.1.0
*/
export type UserEventWheelOptions = UserEventWheelDeltaOptions | UserEventWheelDirectionOptions

export interface LocatorOptions {
/**
* Whether to find an exact match: case-sensitive and whole-string. Default to false. Ignored when locating by a
Expand Down Expand Up @@ -489,6 +560,24 @@ export interface Locator extends LocatorSelectors {
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-tripleclick}
*/
tripleClick(options?: UserEventTripleClickOptions): Promise<void>
/**
* Triggers a {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event|`wheel` event} on an element.
*
* @param options - Scroll configuration using `delta` or `direction`.
* @returns A promise that resolves when all wheel events have been dispatched.
*
* @since 4.1.0
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
*
* @example
* // Scroll down by 100 pixels
* await container.wheel({ delta: { y: 100 } })
*
* @example
* // Scroll up 5 times
* await container.wheel({ direction: 'up', times: 5 })
*/
wheel(options: UserEventWheelOptions): Promise<void>
/**
* Clears the input element content
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-clear}
Expand Down
31 changes: 30 additions & 1 deletion packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import type {
Locator,
LocatorSelectors,
UserEvent,
UserEventWheelOptions,
} from 'vitest/browser'
import type { StringifyOptions } from 'vitest/internal/browser'
import type { IframeViewportEvent } from '../client'
import type { BrowserRunnerState } from '../utils'
import type { Locator as LocatorAPI } from './locators/index'
import { __INTERNAL, stringify } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
import { convertToSelector, processTimeoutOptions } from './tester-utils'
import { convertToSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from './tester-utils'

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

Expand Down Expand Up @@ -69,6 +70,9 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
tripleClick(element, options) {
return convertToLocator(element).tripleClick(options)
},
wheel(elementOrOptions: Element | Locator, options: UserEventWheelOptions) {
return convertToLocator(elementOrOptions).wheel(options)
},
selectOptions(element, value, options) {
return convertToLocator(element).selectOptions(value, options)
},
Expand Down Expand Up @@ -230,6 +234,31 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
async paste() {
await userEvent.paste(clipboardData)
},
async wheel(element: Element | Locator, options: UserEventWheelOptions) {
const resolvedElement = isLocator(element) ? element.element() : element
const resolvedOptions = resolveUserEventWheelOptions(options)

const rect = resolvedElement.getBoundingClientRect()

const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

const wheelEvent = new WheelEvent('wheel', {
clientX: centerX,
clientY: centerY,
deltaY: resolvedOptions.delta.y ?? 0,
deltaX: resolvedOptions.delta.x ?? 0,
deltaMode: 0,
bubbles: true,
cancelable: true,
})

const times = options.times ?? 1

for (let count = 0; count < times; count += 1) {
resolvedElement.dispatchEvent(wheelEvent)
}
},
}

for (const [name, fn] of Object.entries(vitestUserEvent)) {
Expand Down
20 changes: 19 additions & 1 deletion packages/browser/src/client/tester/locators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
UserEventHoverOptions,
UserEventSelectOptions,
UserEventUploadOptions,
UserEventWheelOptions,
} from 'vitest/browser'
import {
asLocator,
Expand All @@ -25,7 +26,7 @@ import {
import { page, server, utils } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState } from '../../utils'
import { escapeForTextSelector, isLocator } from '../tester-utils'
import { escapeForTextSelector, isLocator, resolveUserEventWheelOptions } from '../tester-utils'

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

public wheel(options: UserEventWheelOptions): Promise<void> {
return ensureAwaited<void>(async () => {
await this.triggerCommand<void>('__vitest_wheel', this.selector, resolveUserEventWheelOptions(options))

const browser = getBrowserState().config.browser.name

// looks like on Chromium the scroll event gets dispatched a frame later
if (browser === 'chromium' || browser === 'chrome') {
return new Promise((resolve) => {
requestAnimationFrame(() => {
resolve()
})
})
}
Comment on lines +97 to +103
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As said in another comment, I'm not sure if this branching is worth it or if we should just resolve the promise after a frame on all browsers.

The tricky part is that sometimes, on resource-constrained machines (~CI), one frame might not be enough. From my testing this happens rarely tho, maybe a couple of times in 50 runs (with repeats). A "proper" solution would be waiting for the event to fire with a listener, but we risk not resolving the promise if user-code intercepts and stops the propagation of the event.

})
}

public clear(options?: UserEventClearOptions): Promise<void> {
return this.triggerCommand<void>('__vitest_clear', this.selector, options)
}
Expand Down
Loading