diff --git a/src/event/createEvent.ts b/src/event/createEvent.ts index 72f754e6..1d3adbf8 100644 --- a/src/event/createEvent.ts +++ b/src/event/createEvent.ts @@ -9,8 +9,10 @@ import { interface InterfaceMap { ClipboardEvent: {type: ClipboardEvent, init: ClipboardEventInit} InputEvent: {type: InputEvent, init: InputEventInit} + UIEvent: {type: UIEvent, init: UIEventInit} MouseEvent: {type: MouseEvent, init: MouseEventInit} PointerEvent: {type: PointerEvent, init: PointerEventInit} + WheelEvent: {type: WheelEvent, init: WheelEventInit} KeyboardEvent: {type: KeyboardEvent, init: KeyboardEventInit} FocusEvent: {type: FocusEvent, init: FocusEventInit} } @@ -28,6 +30,7 @@ const eventInitializer: { Event: [], FocusEvent: [initUIEvent, initFocusEvent], InputEvent: [initUIEvent, initInputEvent], + UIEvent: [initUIEvent], MouseEvent: [initUIEvent, initUIEventModifiers, initMouseEvent], PointerEvent: [ initUIEvent, @@ -35,6 +38,12 @@ const eventInitializer: { initMouseEvent, initPointerEvent, ], + WheelEvent: [ + initUIEvent, + initUIEventModifiers, + initMouseEvent, + initWheelEvent, + ], KeyboardEvent: [initUIEvent, initUIEventModifiers, initKeyboardEvent], } @@ -77,6 +86,7 @@ function getEventConstructors(window: Window & typeof globalThis) { const DragEvent = window.DragEvent ?? class DragEvent extends MouseEvent {} const PointerEvent = window.PointerEvent ?? class PointerEvent extends MouseEvent {} + const WheelEvent = window.WheelEvent ?? class WheelEvent extends MouseEvent {} const TouchEvent = window.TouchEvent ?? class TouchEvent extends UIEvent {} return { @@ -94,6 +104,7 @@ function getEventConstructors(window: Window & typeof globalThis) { MouseEvent, DragEvent, PointerEvent, + WheelEvent, TouchEvent, } } @@ -222,9 +233,9 @@ function initMouseEvent( pageX, pageY, }: MouseEventInit & - Partial< - Pick - >, + Partial< + Pick + >, ) { assignProps(event, { screenX: sanitizeNumber(screenX), @@ -243,6 +254,18 @@ function initMouseEvent( }) } +function initWheelEvent( + event: WheelEvent, + {deltaMode, deltaX, deltaY, deltaZ}: WheelEventInit, +) { + assignProps(event, { + deltaMode: sanitizeNumber(deltaMode), + deltaX: sanitizeNumber(deltaX), + deltaY: sanitizeNumber(deltaY), + deltaZ: sanitizeNumber(deltaZ), + }) +} + function initPointerEvent( event: PointerEvent, { diff --git a/src/event/eventMap.ts b/src/event/eventMap.ts index 87f22bd5..5626296f 100644 --- a/src/event/eventMap.ts +++ b/src/event/eventMap.ts @@ -129,17 +129,25 @@ export const eventMap = { EventType: 'PointerEvent', defaultInit: {bubbles: false, cancelable: false}, }, + scroll: { + EventType: 'UIEvent', + defaultInit: {bubbles: false, cancelable: false}, + }, submit: { EventType: 'Event', defaultInit: {bubbles: true, cancelable: true}, }, + wheel: { + EventType: 'WheelEvent', + defaultInit: {bubbles: true, cancelable: true, composed: true}, + }, } as const function getEventClass(type: EventType) { return eventMap[type].EventType } -const mouseEvents = ['MouseEvent', 'PointerEvent'] +const mouseEvents = ['MouseEvent', 'PointerEvent', 'WheelEvent'] export function isMouseEvent(type: EventType) { return mouseEvents.includes(getEventClass(type)) } diff --git a/src/index.ts b/src/index.ts index c5e3cdd1..27e1684e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ export {userEvent} from './setup' export type {UserEvent} from './setup/setup' export type {keyboardKey} from './system/keyboard' export type {pointerKey} from './system/pointer' +export type {ScrollOptions} from './utility' export {PointerEventsCheckLevel, type Options} from './options' diff --git a/src/setup/api.ts b/src/setup/api.ts index a25bbc21..09ba9656 100644 --- a/src/setup/api.ts +++ b/src/setup/api.ts @@ -2,7 +2,15 @@ import {click, dblClick, tripleClick, hover, unhover, tab} from '../convenience' import {keyboard} from '../keyboard' import {copy, cut, paste} from '../clipboard' import {pointer} from '../pointer' -import {clear, deselectOptions, selectOptions, type, upload} from '../utility' +import { + clear, + deselectOptions, + scroll, + selectOptions, + type, + upload, + wheel, +} from '../utility' export const userEventApi = { click, @@ -18,7 +26,9 @@ export const userEventApi = { pointer, clear, deselectOptions, + scroll, selectOptions, type, upload, + wheel, } diff --git a/src/setup/directApi.ts b/src/setup/directApi.ts index c27066c7..4bb10fce 100644 --- a/src/setup/directApi.ts +++ b/src/setup/directApi.ts @@ -1,5 +1,6 @@ import {type Options} from '../options' import {type PointerInput} from '../pointer' +import {type ScrollOptions} from '../utility' import {type System} from '../system' import {setupDirect, type UserEventApi} from './setup' @@ -62,6 +63,14 @@ export function paste( return setupDirect(options).api.paste(clipboardData) } +export function scroll( + element: Element, + scrollOptions: ScrollOptions = {}, + options: DirectOptions = {}, +) { + return setupDirect(options, element).api.scroll(element, scrollOptions) +} + export function selectOptions( select: Element, values: HTMLElement | HTMLElement[] | string[] | string, @@ -89,6 +98,14 @@ export function unhover(element: Element, options: DirectOptions = {}) { return api.unhover(element) } +export function wheel( + element: Element, + init: WheelEventInit = {}, + options: DirectOptions = {}, +) { + return setupDirect(options, element).api.wheel(element, init) +} + export function upload( element: HTMLElement, fileOrFiles: File | File[], diff --git a/src/utility/index.ts b/src/utility/index.ts index 98bd2f88..29876750 100644 --- a/src/utility/index.ts +++ b/src/utility/index.ts @@ -1,4 +1,5 @@ export * from './clear' +export * from './scroll' export * from './selectOptions' export * from './type' export * from './upload' diff --git a/src/utility/scroll.ts b/src/utility/scroll.ts new file mode 100644 index 00000000..3f3bbf85 --- /dev/null +++ b/src/utility/scroll.ts @@ -0,0 +1,37 @@ +import {type Instance} from '../setup' + +export interface ScrollOptions { + left?: number + top?: number +} + +/** + * Set an element's scroll position and dispatch a scroll event. + * + * This does not calculate layout, scroll limits, or intermediate scroll positions. + */ +export async function scroll( + this: Instance, + element: Element, + {left, top}: ScrollOptions = {}, +) { + if (left !== undefined) { + element.scrollLeft = left + } + if (top !== undefined) { + element.scrollTop = top + } + + this.dispatchUIEvent(element, 'scroll') +} + +/** + * Dispatch a wheel event on an element. + */ +export async function wheel( + this: Instance, + element: Element, + init: WheelEventInit = {}, +) { + this.dispatchUIEvent(element, 'wheel', init) +} diff --git a/tests/setup/index.ts b/tests/setup/index.ts index 3cdb36fa..7b08c99e 100644 --- a/tests/setup/index.ts +++ b/tests/setup/index.ts @@ -38,6 +38,10 @@ const apiDeclarations: ApiDeclarations = { pointer: { args: ['foo'], }, + scroll: { + args: [null, {}], + elementArg: 0, + }, selectOptions: { args: [null, ['foo']], elementArg: 0, @@ -64,6 +68,10 @@ const apiDeclarations: ApiDeclarations = { elementArg: 0, elementHtml: ``, }, + wheel: { + args: [null, {}], + elementArg: 0, + }, } type ApiDeclarationsEntry< diff --git a/tests/utility/scroll.ts b/tests/utility/scroll.ts new file mode 100644 index 00000000..258ab4b7 --- /dev/null +++ b/tests/utility/scroll.ts @@ -0,0 +1,69 @@ +import userEvent from '#src' +import {setup} from '#testHelpers' + +describe('scroll', () => { + test('sets the supplied scroll positions before dispatching a non-bubbling scroll event', async () => { + const {element, getEvents, user} = setup('
') + const onParentScroll = mocks.fn() + let scrollPosition: {left: number, top: number} | undefined + element.parentElement?.addEventListener('scroll', onParentScroll) + element.addEventListener('scroll', () => { + scrollPosition = {left: element.scrollLeft, top: element.scrollTop} + }) + element.scrollLeft = 10 + + await user.scroll(element, {top: 20}) + + expect(element.scrollLeft).toBe(10) + expect(element.scrollTop).toBe(20) + expect(scrollPosition).toEqual({left: 10, top: 20}) + expect(getEvents('scroll')).toHaveLength(1) + expect(getEvents('scroll')[0]).toMatchObject({ + bubbles: false, + cancelable: false, + }) + expect(onParentScroll).not.toHaveBeenCalled() + }) + + test('is available from the direct API', async () => { + const element = document.createElement('div') + const onScroll = mocks.fn() + element.addEventListener('scroll', onScroll) + + await userEvent.scroll(element, {left: 20, top: 30}) + + expect(element.scrollLeft).toBe(20) + expect(element.scrollTop).toBe(30) + expect(onScroll).toHaveBeenCalledTimes(1) + }) +}) + +describe('wheel', () => { + test('dispatches a wheel event with the supplied deltas', async () => { + const {element, getEvents, user} = setup('
') + + await user.wheel(element, { + deltaMode: WheelEvent.DOM_DELTA_LINE, + deltaX: 10, + deltaY: -20, + deltaZ: 30, + }) + + const [event] = getEvents('wheel') + expect(event).toMatchObject({ + deltaMode: WheelEvent.DOM_DELTA_LINE, + deltaX: 10, + deltaY: -20, + deltaZ: 30, + }) + }) + + test('includes pressed modifier keys', async () => { + const {element, getEvents, user} = setup('
') + await user.keyboard('[ShiftLeft>]') + + await user.wheel(element) + + expect(getEvents('wheel')[0].shiftKey).toBe(true) + }) +})