24725216e4
* add watch script * make interactions in Vue and React consistent * re-work focus restoration When we click outside of the Menu or Listbox, we want to restore the focus to the Button, *unless* we clicked on/in an element that is focusable in itself. For example, when the Menu is open and you click in an input field, the input field should stay focused. We should also close the Menu itself at this point. * add examples with multiple elements * bump dependencies
193 lines
4.8 KiB
TypeScript
193 lines
4.8 KiB
TypeScript
import { fireEvent } from '@testing-library/dom'
|
|
|
|
function nextFrame(cb: Function): void {
|
|
setImmediate(() =>
|
|
setImmediate(() => {
|
|
cb()
|
|
})
|
|
)
|
|
}
|
|
|
|
export const Keys: Record<string, Partial<KeyboardEvent>> = {
|
|
Space: { key: ' ', keyCode: 32 },
|
|
Enter: { key: 'Enter', keyCode: 13 },
|
|
Escape: { key: 'Escape', keyCode: 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 },
|
|
}
|
|
|
|
export function shift(event: Partial<KeyboardEvent>) {
|
|
return { ...event, shiftKey: true }
|
|
}
|
|
|
|
export function word(input: string): Partial<KeyboardEvent>[] {
|
|
return input.split('').map(key => ({ key }))
|
|
}
|
|
|
|
export async function type(events: Partial<KeyboardEvent>[]) {
|
|
jest.useFakeTimers()
|
|
|
|
try {
|
|
if (document.activeElement === null) return expect(document.activeElement).not.toBe(null)
|
|
|
|
let element = document.activeElement
|
|
|
|
events.forEach(event => {
|
|
const cancelled1 = !fireEvent.keyDown(element, event)
|
|
|
|
// Special treatment for `Tab` on an element
|
|
if (!cancelled1 && event.key === Keys.Tab.key) {
|
|
element = focusNext(event)
|
|
}
|
|
|
|
const cancelled2 = !fireEvent.keyPress(element, event)
|
|
// Special treatment for `Enter` on a button element
|
|
if (!cancelled2 && event.key === Keys.Enter.key && element instanceof HTMLButtonElement) {
|
|
fireEvent.click(element)
|
|
}
|
|
|
|
fireEvent.keyUp(element, event)
|
|
})
|
|
|
|
// 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 | 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 | 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))
|
|
}
|