import { fireEvent } from '@testing-library/react' function nextFrame(cb: Function): void { setImmediate(() => setImmediate(() => { cb() }) ) } export const Keys: Record> = { 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) { return { ...event, shiftKey: true } } export function word(input: string): Partial[] { return input.split('').map(key => ({ key })) } let Default = Symbol() let Ignore = Symbol() let cancellations: Record>> = { [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 ) => 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[], 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) { 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) { 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)) }