Skip to content

Commit 14530f8

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

11 files changed

Lines changed: 337 additions & 3 deletions

File tree

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, WheelOptionsWithDelta } from 'vitest/browser'
2+
import type { UserEventCommand } from './utils'
3+
import { hover } from './hover'
4+
5+
type WheelCommand = (element: Locator | Element, options: WheelOptionsWithDelta) => 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, WheelOptionsWithDelta } from 'vitest/browser'
2+
import type { UserEventCommand } from './utils'
3+
4+
type WheelCommand = (element: Locator | Element, options: WheelOptionsWithDelta) => 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: 74 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+
* Simulates wheel events 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: WheelOptions): 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,43 @@ export interface UserEventDoubleClickOptions {}
344362
export interface UserEventTripleClickOptions {}
345363
export interface UserEventDragAndDropOptions {}
346364
export interface UserEventUploadOptions {}
365+
/**
366+
* Options for simulating wheel events.
367+
*
368+
* Specify scrolling using either `delta` for precise pixel values, or `direction` for semantic scrolling. These are mutually exclusive.
369+
*
370+
* @example
371+
* // Precise control with delta
372+
* { delta: { y: -100 } }
373+
*
374+
* @example
375+
* // Semantic scrolling with direction
376+
* { direction: 'down', times: 3 }
377+
*/
378+
export type WheelOptions = ExactlyOneOf<
379+
{
380+
/**
381+
* Precise scroll delta values in pixels. At least one axis must be specified.
382+
*
383+
* - Positive `y` scrolls down, negative `y` scrolls up.
384+
* - Positive `x` scrolls right, negative `x` scrolls left.
385+
*/
386+
delta: AtLeastOneOf<{ x: number, y: number }>,
387+
/**
388+
* Semantic scroll direction. Use this for readable tests when exact pixel values don't matter.
389+
*/
390+
direction: 'up' | 'down' | 'left' | 'right',
391+
/**
392+
* Number of wheel events to fire. Defaults to `1`.
393+
*
394+
* Useful for simulating multiple scroll steps in a single call.
395+
*/
396+
times?: number,
397+
},
398+
'delta' | 'direction'
399+
>
400+
401+
export type WheelOptionsWithDelta = Extract<WheelOptions, { delta: { x?: number; y?: number } }>
347402

348403
export interface LocatorOptions {
349404
/**
@@ -489,6 +544,24 @@ export interface Locator extends LocatorSelectors {
489544
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-tripleclick}
490545
*/
491546
tripleClick(options?: UserEventTripleClickOptions): Promise<void>
547+
/**
548+
* Simulates wheel events on an element.
549+
*
550+
* @param element - The target element to receive wheel events.
551+
* @param options - Scroll configuration using `delta` or `direction`.
552+
* @returns A promise that resolves when all wheel events have been dispatched.
553+
*
554+
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
555+
*
556+
* @example
557+
* // Scroll down by 100 pixels
558+
* await container.wheel({ delta: { y: 100 } })
559+
*
560+
* @example
561+
* // Scroll up 5 times
562+
* await container.wheel({ direction: 'up', times: 5 })
563+
*/
564+
wheel(options: WheelOptions): Promise<void>
492565
/**
493566
* Clears the input element content
494567
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-clear}

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ import type {
99
Locator,
1010
LocatorSelectors,
1111
UserEvent,
12+
WheelOptions,
13+
WheelOptionsWithDelta,
1214
} from 'vitest/browser'
1315
import type { StringifyOptions } from 'vitest/internal/browser'
1416
import type { IframeViewportEvent } from '../client'
1517
import type { BrowserRunnerState } from '../utils'
1618
import type { Locator as LocatorAPI } from './locators/index'
1719
import { __INTERNAL, stringify } from 'vitest/internal/browser'
1820
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
19-
import { convertToSelector, processTimeoutOptions } from './tester-utils'
21+
import { convertToSelector, isLocator, processTimeoutOptions } from './tester-utils'
2022

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

@@ -25,6 +27,44 @@ const provider = __vitest_browser_runner__.provider
2527
const sessionId = getBrowserState().sessionId
2628
const channel = new BroadcastChannel(`vitest:${sessionId}`)
2729

30+
const DEFAULT_SCROLL_DISTANCE = 100
31+
32+
function resolveWheelOptions(options: WheelOptions): WheelOptionsWithDelta {
33+
let delta: WheelOptionsWithDelta['delta']
34+
35+
if (options.delta) {
36+
delta = options.delta
37+
}
38+
else {
39+
switch (options.direction) {
40+
case 'up': {
41+
delta = { x: 0, y: -DEFAULT_SCROLL_DISTANCE }
42+
break
43+
}
44+
45+
case 'down': {
46+
delta = { x: 0, y: DEFAULT_SCROLL_DISTANCE }
47+
break
48+
}
49+
50+
case 'left': {
51+
delta = { x: -DEFAULT_SCROLL_DISTANCE, y: 0 }
52+
break
53+
}
54+
55+
case 'right': {
56+
delta = { x: DEFAULT_SCROLL_DISTANCE, y: 0 }
57+
break
58+
}
59+
}
60+
}
61+
62+
return {
63+
delta,
64+
times: options.times,
65+
}
66+
}
67+
2868
function triggerCommand<T>(command: string, args: any[], error?: Error) {
2969
return getBrowserState().commands.triggerCommand<T>(command, args, error)
3070
}
@@ -69,6 +109,22 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
69109
tripleClick(element, options) {
70110
return convertToLocator(element).tripleClick(options)
71111
},
112+
wheel(elementOrOptions: Element | Locator, options: WheelOptions) {
113+
return ensureAwaited<void>(async () => {
114+
await convertToLocator(elementOrOptions).wheel(resolveWheelOptions(options))
115+
116+
const browser = getBrowserState().config.browser.name
117+
118+
// looks like on Chromium the scroll event gets dispatched a frame later
119+
if (browser === 'chromium' || browser === 'chrome') {
120+
return new Promise((resolve) => {
121+
requestAnimationFrame(() => {
122+
resolve()
123+
})
124+
})
125+
}
126+
})
127+
},
72128
selectOptions(element, value, options) {
73129
return convertToLocator(element).selectOptions(value, options)
74130
},
@@ -230,6 +286,27 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
230286
async paste() {
231287
await userEvent.paste(clipboardData)
232288
},
289+
async wheel(element: Element | Locator, options: WheelOptions) {
290+
const resolvedElement = isLocator(element) ? element.element() : element
291+
const resolvedOptions = resolveWheelOptions(options)
292+
293+
const rect = resolvedElement.getBoundingClientRect()
294+
295+
const centerX = rect.left + rect.width / 2
296+
const centerY = rect.top + rect.height / 2
297+
298+
const wheelEvent = new WheelEvent('wheel', {
299+
clientX: centerX,
300+
clientY: centerY,
301+
deltaY: resolvedOptions.delta.y ?? 0,
302+
deltaX: resolvedOptions.delta.x ?? 0,
303+
deltaMode: 0,
304+
bubbles: true,
305+
cancelable: true,
306+
})
307+
308+
resolvedElement.dispatchEvent(wheelEvent)
309+
},
233310
}
234311

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

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
UserEventHoverOptions,
1111
UserEventSelectOptions,
1212
UserEventUploadOptions,
13+
WheelOptions,
1314
} from 'vitest/browser'
1415
import {
1516
asLocator,
@@ -86,6 +87,10 @@ export abstract class Locator {
8687
return this.triggerCommand<void>('__vitest_tripleClick', this.selector, options)
8788
}
8889

90+
public wheel(options: WheelOptions): Promise<void> {
91+
return this.triggerCommand<void>('__vitest_wheel', this.selector, options)
92+
}
93+
8994
public clear(options?: UserEventClearOptions): Promise<void> {
9095
return this.triggerCommand<void>('__vitest_clear', this.selector, options)
9196
}

packages/utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ export type { SafeTimers } from './timers'
44
export type {
55
ArgumentsType,
66
Arrayable,
7+
AtLeastOneOf,
78
Awaitable,
89
Constructable,
910
DeepMerge,
11+
ExactlyOneOf,
1012
MergeInsertions,
1113
Nullable,
1214
ParsedStack,

packages/utils/src/types.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,51 @@ export interface TestError extends SerializedError {
4343
actual?: string
4444
expected?: string
4545
}
46+
47+
/**
48+
* Requires at least one of the specified keys to be present.
49+
* Other keys in the set remain optional.
50+
*
51+
* @template T - The object type.
52+
* @template K - The keys of which at least one is required. Defaults to all keys.
53+
*
54+
* @example
55+
* type Point = AtLeastOneOf<{ x: number; y: number }>
56+
*
57+
* const a: Point = { x: 1 }
58+
* const b: Point = { y: 2 }
59+
* const c: Point = { x: 1, y: 2 }
60+
*
61+
* @example
62+
* type Action = AtLeastOneOf<{ id: number; name: string; force: boolean }, 'id' | 'name'>
63+
*
64+
* const a: Action = { id: 1, force: false }
65+
* const b: Action = { name: 'foo', force: true }
66+
* const c: Action = { id: 1, name: 'foo', force: true }
67+
*/
68+
export type AtLeastOneOf<T extends Record<PropertyKey, unknown>, K extends keyof T = keyof T> = {
69+
[Key in K]: Required<Pick<T, Key>> & Partial<Pick<T, Exclude<K, Key>>>
70+
}[K] & Omit<T, K>
71+
72+
/**
73+
* Requires exactly one of the specified keys to be present.
74+
* Other keys in the set are forbidden.
75+
*
76+
* @template T - The object type.
77+
* @template K - The keys of which exactly one is required. Defaults to all keys.
78+
*
79+
* @example
80+
* type Input = ExactlyOneOf<{ a: string; b: string }>
81+
*
82+
* const a: Input = { a: 'foo' }
83+
* const b: Input = { b: 'bar' }
84+
*
85+
* @example
86+
* type Query = ExactlyOneOf<{ id: number; path: string; all: boolean }, 'id' | 'path'>
87+
*
88+
* const a: Query = { id: 1, all: true }
89+
* const b: Query = { path: 'foo', fall: false }
90+
*/
91+
export type ExactlyOneOf<T extends Record<PropertyKey, unknown>, K extends keyof T = keyof T> = {
92+
[Key in K]: Required<Pick<T, Key>> & Partial<Record<Exclude<K, Key>, never>>
93+
}[K] & Omit<T, K>

packages/vitest/src/public/browser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export {
1010
} from '../runtime/setup-common'
1111
export { collectTests, startTests } from '@vitest/runner'
1212
export * as SpyModule from '@vitest/spy'
13-
export type { LoupeOptions, ParsedStack, StringifyOptions } from '@vitest/utils'
13+
export type { AtLeastOneOf, ExactlyOneOf, LoupeOptions, ParsedStack, StringifyOptions } from '@vitest/utils'
1414
export {
1515
format,
1616
inspect,

0 commit comments

Comments
 (0)