4459689beb
Browsers. Are. Crazy. In JSDOM, when you fire an event, you only get that specific event. You don't get all the magic that the browser gives you. For example, when you are focused on a button and press to "Tab" then in JSDOM you would only get a keydown event. However in the browser you get this chain of events: 1. `keydown` on the current element 2. `blur` on the current element 3. `focus` on the new element 4. `keyup` on the new element I implemented this "magic", for the `Tab`, `Enter` and `Space` key for now. Those are the most important currently. `Enter` and `Space` also trigger `click` events for example. I also have a "generic" implementation, where a normal press results in: 1. `keydown` 2. `keypress` (in case it has a `charCode` and is "printable", so `alt` is ignored) 3. `keyup` I also ensured that the cancelation when you use an `event.preventDefault()` happens correctly. Here is a fun summary: https://twitter.com/malfaitrobin/status/1354472678128820234 Press "Enter" on a button -> keydown, keypress, click, keyup Press "Space" on a button -> keydown, keypress, keyup, click Press "Enter" or "Space" on a button, with event.preventDefault() in the keydown listener -> keydown, keyup Press "Enter" on a button, with event.preventDefault() in the keypress listener -> keydown, keypress, keyup Press "Space" on a button, with event.preventDefault() in the keypress listener -> keydown, keypress, keyup, click
287 lines
7.5 KiB
TypeScript
287 lines
7.5 KiB
TypeScript
import { fireEvent } from '@testing-library/react'
|
|
|
|
function nextFrame(cb: Function): void {
|
|
setImmediate(() =>
|
|
setImmediate(() => {
|
|
cb()
|
|
})
|
|
)
|
|
}
|
|
|
|
export const Keys: Record<string, Partial<KeyboardEvent>> = {
|
|
Space: { key: ' ', keyCode: 32, charCode: 32 },
|
|
Enter: { key: 'Enter', keyCode: 13, charCode: 13 },
|
|
Escape: { key: 'Escape', keyCode: 27, charCode: 27 },
|
|
Backspace: { key: 'Backspace', keyCode: 8 },
|
|
|
|
ArrowUp: { key: 'ArrowUp', keyCode: 38 },
|
|
ArrowDown: { key: 'ArrowDown', keyCode: 40 },
|
|
|
|
Home: { key: 'Home', keyCode: 36 },
|
|
End: { key: 'End', keyCode: 35 },
|
|
|
|
PageUp: { key: 'PageUp', keyCode: 33 },
|
|
PageDown: { key: 'PageDown', keyCode: 34 },
|
|
|
|
Tab: { key: 'Tab', keyCode: 9, charCode: 9 },
|
|
}
|
|
|
|
export function shift(event: Partial<KeyboardEvent>) {
|
|
return { ...event, shiftKey: true }
|
|
}
|
|
|
|
export function word(input: string): Partial<KeyboardEvent>[] {
|
|
return input.split('').map(key => ({ key }))
|
|
}
|
|
|
|
let Default = Symbol()
|
|
let Ignore = Symbol()
|
|
|
|
let cancellations: Record<string | typeof Default, Record<string, Set<string>>> = {
|
|
[Default]: {
|
|
keydown: new Set(['keypress']),
|
|
keypress: new Set([]),
|
|
keyup: new Set([]),
|
|
},
|
|
[Keys.Enter.key!]: {
|
|
keydown: new Set(['keypress', 'click']),
|
|
keypress: new Set(['click']),
|
|
keyup: new Set([]),
|
|
},
|
|
[Keys.Space.key!]: {
|
|
keydown: new Set(['keypress', 'click']),
|
|
keypress: new Set([]),
|
|
keyup: new Set(['click']),
|
|
},
|
|
[Keys.Tab.key!]: {
|
|
keydown: new Set(['keypress', 'blur', 'focus']),
|
|
keypress: new Set([]),
|
|
keyup: new Set([]),
|
|
},
|
|
}
|
|
|
|
let order: Record<
|
|
string | typeof Default,
|
|
((
|
|
element: Element,
|
|
event: Partial<KeyboardEvent | MouseEvent>
|
|
) => boolean | typeof Ignore | Element)[]
|
|
> = {
|
|
[Default]: [
|
|
function keydown(element, event) {
|
|
return fireEvent.keyDown(element, event)
|
|
},
|
|
function keypress(element, event) {
|
|
return fireEvent.keyPress(element, event)
|
|
},
|
|
function keyup(element, event) {
|
|
return fireEvent.keyUp(element, event)
|
|
},
|
|
],
|
|
[Keys.Enter.key!]: [
|
|
function keydown(element, event) {
|
|
return fireEvent.keyDown(element, event)
|
|
},
|
|
function keypress(element, event) {
|
|
return fireEvent.keyPress(element, event)
|
|
},
|
|
function click(element, event) {
|
|
if (element instanceof HTMLButtonElement) return fireEvent.click(element, event)
|
|
return Ignore
|
|
},
|
|
function keyup(element, event) {
|
|
return fireEvent.keyUp(element, event)
|
|
},
|
|
],
|
|
[Keys.Space.key!]: [
|
|
function keydown(element, event) {
|
|
return fireEvent.keyDown(element, event)
|
|
},
|
|
function keypress(element, event) {
|
|
return fireEvent.keyPress(element, event)
|
|
},
|
|
function keyup(element, event) {
|
|
return fireEvent.keyUp(element, event)
|
|
},
|
|
function click(element, event) {
|
|
if (element instanceof HTMLButtonElement) return fireEvent.click(element, event)
|
|
return Ignore
|
|
},
|
|
],
|
|
[Keys.Tab.key!]: [
|
|
function keydown(element, event) {
|
|
return fireEvent.keyDown(element, event)
|
|
},
|
|
function blurAndfocus(_element, event) {
|
|
return focusNext(event)
|
|
},
|
|
function keyup(element, event) {
|
|
return fireEvent.keyUp(element, event)
|
|
},
|
|
],
|
|
}
|
|
|
|
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {
|
|
jest.useFakeTimers()
|
|
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
for (let event of events) {
|
|
let skip = new Set()
|
|
let actions = order[event.key!] ?? order[Default as any]
|
|
for (let action of actions) {
|
|
let checks = action.name.split('And')
|
|
if (checks.some(check => skip.has(check))) continue
|
|
|
|
let result = action(element, {
|
|
type: action.name,
|
|
charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined,
|
|
...event,
|
|
})
|
|
if (result === Ignore) continue
|
|
if (result instanceof Element) {
|
|
element = result
|
|
}
|
|
|
|
let cancelled = !result
|
|
if (cancelled) {
|
|
let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any]
|
|
let skippables = skippablesForKey?.[action.name] ?? new Set()
|
|
|
|
for (let skippable of skippables) skip.add(skippable)
|
|
}
|
|
}
|
|
}
|
|
|
|
// We don't want to actually wait in our tests, so let's advance
|
|
jest.runAllTimers()
|
|
|
|
await new Promise(nextFrame)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, type)
|
|
throw err
|
|
} finally {
|
|
jest.useRealTimers()
|
|
}
|
|
}
|
|
|
|
export async function press(event: Partial<KeyboardEvent>) {
|
|
return type([event])
|
|
}
|
|
|
|
export async function click(element: Document | Element | Window | Node | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
fireEvent.pointerDown(element)
|
|
fireEvent.mouseDown(element)
|
|
fireEvent.pointerUp(element)
|
|
fireEvent.mouseUp(element)
|
|
fireEvent.click(element)
|
|
|
|
await new Promise(nextFrame)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, click)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function focus(element: Document | Element | Window | Node | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
fireEvent.focus(element)
|
|
|
|
await new Promise(nextFrame)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, focus)
|
|
throw err
|
|
}
|
|
}
|
|
export async function mouseEnter(element: Document | Element | Window | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
fireEvent.pointerOver(element)
|
|
fireEvent.pointerEnter(element)
|
|
fireEvent.mouseOver(element)
|
|
|
|
await new Promise(nextFrame)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, mouseEnter)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function mouseMove(element: Document | Element | Window | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
fireEvent.pointerMove(element)
|
|
fireEvent.mouseMove(element)
|
|
|
|
await new Promise(nextFrame)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, mouseMove)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function mouseLeave(element: Document | Element | Window | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
fireEvent.pointerOut(element)
|
|
fireEvent.pointerLeave(element)
|
|
fireEvent.mouseOut(element)
|
|
fireEvent.mouseLeave(element)
|
|
|
|
await new Promise(nextFrame)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, mouseLeave)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
function focusNext(event: Partial<KeyboardEvent>) {
|
|
const direction = event.shiftKey ? -1 : +1
|
|
const focusableElements = getFocusableElements()
|
|
const total = focusableElements.length
|
|
|
|
function innerFocusNext(offset = 0): Element {
|
|
const currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement)
|
|
const next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement
|
|
|
|
if (next) next?.focus({ preventScroll: true })
|
|
|
|
if (next !== document.activeElement) return innerFocusNext(offset + direction)
|
|
return next
|
|
}
|
|
|
|
return innerFocusNext()
|
|
}
|
|
|
|
// Credit:
|
|
// - https://stackoverflow.com/a/30753870
|
|
const focusableSelector = [
|
|
'[contentEditable=true]',
|
|
'[tabindex]',
|
|
'a[href]',
|
|
'area[href]',
|
|
'button:not([disabled])',
|
|
'iframe',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
]
|
|
.map(selector => `${selector}:not([tabindex='-1'])`)
|
|
.join(',')
|
|
|
|
function getFocusableElements(container = document.body) {
|
|
if (!container) return []
|
|
return Array.from(container.querySelectorAll(focusableSelector))
|
|
}
|