diff --git a/docs/api/browser/interactivity.md b/docs/api/browser/interactivity.md index 8d2697b687cd..b24441b80d46 100644 --- a/docs/api/browser/interactivity.md +++ b/docs/api/browser/interactivity.md @@ -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 4.1.0 {#userevent-wheel} + +```ts +function wheel( + element: Element | Locator, + options: UserEventWheelOptions, +): Promise +``` + +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 diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index 44e17f28715c..e3cc3fce453e 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -648,6 +648,23 @@ await page.getByRole('img', { name: 'Rose' }).tripleClick() - [See more at `userEvent.tripleClick`](/api/browser/interactivity#userevent-tripleclick) +### wheel 4.1.0 {#wheel} + +```ts +function wheel(options: UserEventWheelOptions): Promise +``` + +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 diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts index f143414771dd..a5d1759ede52 100644 --- a/packages/browser-playwright/src/commands/index.ts +++ b/packages/browser-playwright/src/commands/index.ts @@ -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, diff --git a/packages/browser-playwright/src/commands/wheel.ts b/packages/browser-playwright/src/commands/wheel.ts new file mode 100644 index 000000000000..f63acc96f890 --- /dev/null +++ b/packages/browser-playwright/src/commands/wheel.ts @@ -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 + +export const wheel: UserEventCommand = 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) + } +} diff --git a/packages/browser-webdriverio/src/commands/index.ts b/packages/browser-webdriverio/src/commands/index.ts index a589c0265fb6..3fbb5b7f90be 100644 --- a/packages/browser-webdriverio/src/commands/index.ts +++ b/packages/browser-webdriverio/src/commands/index.ts @@ -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, diff --git a/packages/browser-webdriverio/src/commands/wheel.ts b/packages/browser-webdriverio/src/commands/wheel.ts new file mode 100644 index 000000000000..9a06417e88c0 --- /dev/null +++ b/packages/browser-webdriverio/src/commands/wheel.ts @@ -0,0 +1,28 @@ +import type { Locator, UserEventWheelDeltaOptions } from 'vitest/browser' +import type { UserEventCommand } from './utils' + +type WheelCommand = (element: Locator | Element, options: UserEventWheelDeltaOptions) => Promise + +export const wheel: UserEventCommand = 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[0] = { + deltaX, + deltaY, + origin: browser.$(selector), + } + + for (let count = 0; count < times; count += 1) { + action = action.scroll(wheelOptions) + } + + await action.perform() +} diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index c4bf8104eb94..ef1c524e8462 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -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 + /** + * 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 /** * 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. @@ -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 @@ -489,6 +560,24 @@ export interface Locator extends LocatorSelectors { * @see {@link https://vitest.dev/api/browser/interactivity#userevent-tripleclick} */ tripleClick(options?: UserEventTripleClickOptions): Promise + /** + * 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 /** * Clears the input element content * @see {@link https://vitest.dev/api/browser/interactivity#userevent-clear} diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 9103bd10fc5b..8229969ea444 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -9,6 +9,7 @@ import type { Locator, LocatorSelectors, UserEvent, + UserEventWheelOptions, } from 'vitest/browser' import type { StringifyOptions } from 'vitest/internal/browser' import type { IframeViewportEvent } from '../client' @@ -16,7 +17,7 @@ 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 @@ -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) }, @@ -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)) { diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 14a715c9064a..1f1c1566c740 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -10,6 +10,7 @@ import type { UserEventHoverOptions, UserEventSelectOptions, UserEventUploadOptions, + UserEventWheelOptions, } from 'vitest/browser' import { asLocator, @@ -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 { @@ -86,6 +87,23 @@ export abstract class Locator { return this.triggerCommand('__vitest_tripleClick', this.selector, options) } + public wheel(options: UserEventWheelOptions): Promise { + return ensureAwaited(async () => { + await this.triggerCommand('__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() + }) + }) + } + }) + } + public clear(options?: UserEventClearOptions): Promise { return this.triggerCommand('__vitest_clear', this.selector, options) } diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index c22fef6c4c7c..e69eac071140 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -1,4 +1,4 @@ -import type { Locator } from 'vitest/browser' +import type { Locator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' import type { BrowserRPC } from '../client' import { getBrowserState, getWorkerState } from '../utils' @@ -217,3 +217,41 @@ const kLocator = Symbol.for('$$vitest:locator') export function isLocator(element: unknown): element is Locator { return (!!element && typeof element === 'object' && kLocator in element) } + +const DEFAULT_WHEEL_DELTA = 100 + +export function resolveUserEventWheelOptions(options: UserEventWheelOptions): UserEventWheelDeltaOptions { + let delta: UserEventWheelDeltaOptions['delta'] + + if (options.delta) { + delta = options.delta + } + else { + switch (options.direction) { + case 'up': { + delta = { y: -DEFAULT_WHEEL_DELTA } + break + } + + case 'down': { + delta = { y: DEFAULT_WHEEL_DELTA } + break + } + + case 'left': { + delta = { x: -DEFAULT_WHEEL_DELTA } + break + } + + case 'right': { + delta = { x: DEFAULT_WHEEL_DELTA } + break + } + } + } + + return { + delta, + times: options.times, + } +} diff --git a/test/browser/fixtures/user-event/wheel.test.ts b/test/browser/fixtures/user-event/wheel.test.ts new file mode 100644 index 000000000000..950d9e4af5b8 --- /dev/null +++ b/test/browser/fixtures/user-event/wheel.test.ts @@ -0,0 +1,85 @@ +import { describe, test, vi } from 'vitest' +import { userEvent, page } from 'vitest/browser' + +describe.for([ + 'userEvent', + 'locator' +] as const)('`%s`', (testType) => { + test.for([ + ['up', { deltaX: 0, deltaY: -100 }], + ['down', { deltaX: 0, deltaY: 100 }], + ['left', { deltaX: -100, deltaY: 0 }], + ['right', { deltaX: 100, deltaY: 0 }] + ] as const)('scrolls %s with default delta of 100', async ([direction, { deltaX, deltaY }], { expect }) => { + document.body.innerHTML = ` +
+ +
+ `; + + const wheel = vi.fn<(event: WheelEvent) => void>() + document.body.querySelector('button').addEventListener('wheel', wheel, { passive: true }) + + const options = { direction } + const selector = page.getByRole("button") + + await (testType === 'userEvent' ? userEvent.wheel(selector, options) : selector.wheel(options)) + + expect(wheel).toHaveBeenCalledOnce() + expect(wheel.mock.calls[0][0].deltaX).toBe(deltaX) + expect(wheel.mock.calls[0][0].deltaY).toBe(deltaY) + }) + + test.for([ + { deltaX: 0, deltaY: -50 }, + { deltaX: 0, deltaY: 50 }, + { deltaX: -50, deltaY: 0 }, + { deltaX: 50, deltaY: 0 } + ] as const)('scrolls with custom delta values %o', async ({ deltaX, deltaY }, { expect }) => { + document.body.innerHTML = ` +
+ +
+ `; + + const wheel = vi.fn<(event: WheelEvent) => void>() + document.body.querySelector('button').addEventListener('wheel', wheel, { passive: true }) + + const options = { + delta: { + x: deltaX, + y: deltaY, + }, + } + const selector = page.getByRole("button") + + await (testType === 'userEvent' ? userEvent.wheel(selector, options) : selector.wheel(options)) + + expect(wheel).toHaveBeenCalledOnce() + expect(wheel.mock.calls[0][0].deltaX).toBe(deltaX) + expect(wheel.mock.calls[0][0].deltaY).toBe(deltaY) + }) + + test("fires wheel event multiple times when `times` option is set", { retry: 5 }, async ({ expect }) => { + document.body.innerHTML = ` +
+ +
+ `; + + const wheel = vi.fn<(event: WheelEvent) => void>() + document.body.querySelector('button').addEventListener('wheel', wheel, { passive: true }) + + const options = { direction: 'down', times: 5 } as const + const selector = page.getByRole("button") + + await (testType === 'userEvent' ? userEvent.wheel(selector, options) : selector.wheel(options)) + + expect(wheel).toHaveBeenCalledTimes(5) + + for (const call of wheel.mock.calls) { + expect(call[0].deltaX).toBe(0) + expect(call[0].deltaY).toBe(100) + } + }) +})