Skip to content

Commit

Permalink
feat: dispatch FocusEvent in hidden documents (#1252)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche authored Jan 15, 2025
1 parent 63f7468 commit 1ed8b15
Show file tree
Hide file tree
Showing 18 changed files with 327 additions and 74 deletions.
104 changes: 104 additions & 0 deletions src/document/patchFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {dispatchDOMEvent} from '../event'
import {getActiveElement} from '../utils'

const patched = Symbol('patched focus/blur methods')

declare global {
interface HTMLElement {
readonly [patched]?: Pick<HTMLElement, 'focus' | 'blur'>
}
}

export function patchFocus(HTMLElement: typeof globalThis['HTMLElement']) {
if (HTMLElement.prototype[patched]) {
return
}

// eslint-disable-next-line @typescript-eslint/unbound-method
const {focus, blur} = HTMLElement.prototype

Object.defineProperties(HTMLElement.prototype, {
focus: {
configurable: true,
get: () => patchedFocus,
},
blur: {
configurable: true,
get: () => patchedBlur,
},
[patched]: {
configurable: true,
get: () => ({focus, blur}),
},
})

let activeCall: symbol

function patchedFocus(this: HTMLElement, options: FocusOptions) {
if (this.ownerDocument.visibilityState !== 'hidden') {
return focus.call(this, options)
}

const blured = getActiveTarget(this.ownerDocument)
if (blured === this) {
return
}

const thisCall = Symbol('focus call')
activeCall = thisCall

if (blured) {
blur.call(blured)
dispatchDOMEvent(blured, 'blur', {relatedTarget: this})
dispatchDOMEvent(blured, 'focusout', {
relatedTarget: activeCall === thisCall ? this : null,
})
}
if (activeCall === thisCall) {
focus.call(this, options)
dispatchDOMEvent(this, 'focus', {relatedTarget: blured})
}
if (activeCall === thisCall) {
dispatchDOMEvent(this, 'focusin', {relatedTarget: blured})
}
}

function patchedBlur(this: HTMLElement) {
if (this.ownerDocument.visibilityState !== 'hidden') {
return blur.call(this)
}

const blured = getActiveTarget(this.ownerDocument)
if (blured !== this) {
return
}

const thisCall = Symbol('blur call')
activeCall = thisCall

blur.call(this)
dispatchDOMEvent(blured, 'blur', {relatedTarget: null})
dispatchDOMEvent(blured, 'focusout', {relatedTarget: null})
}
}

function getActiveTarget(document: Document) {
const active = getActiveElement(document)
return active?.tagName === 'BODY' ? null : active
}

export function restoreFocus(HTMLElement: typeof globalThis['HTMLElement']) {
if (HTMLElement.prototype[patched]) {
const {focus, blur} = HTMLElement.prototype[patched]
Object.defineProperties(HTMLElement.prototype, {
focus: {
configurable: true,
get: () => focus,
},
blur: {
configurable: true,
get: () => blur,
},
})
}
}
8 changes: 8 additions & 0 deletions src/event/createEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface InterfaceMap {
MouseEvent: {type: MouseEvent; init: MouseEventInit}
PointerEvent: {type: PointerEvent; init: PointerEventInit}
KeyboardEvent: {type: KeyboardEvent; init: KeyboardEventInit}
FocusEvent: {type: FocusEvent; init: FocusEventInit}
}
type InterfaceNames = typeof eventMap[keyof typeof eventMap]['EventType']
type Interface<k extends InterfaceNames> = k extends keyof InterfaceMap
Expand All @@ -25,6 +26,7 @@ const eventInitializer: {
} = {
ClipboardEvent: [initClipboardEvent],
Event: [],
FocusEvent: [initUIEvent, initFocusEvent],
InputEvent: [initUIEvent, initInputEvent],
MouseEvent: [initUIEvent, initUIEventModififiers, initMouseEvent],
PointerEvent: [
Expand Down Expand Up @@ -117,6 +119,12 @@ function initClipboardEvent(
})
}

function initFocusEvent(event: FocusEvent, {relatedTarget}: FocusEventInit) {
assignProps(event, {
relatedTarget,
})
}

function initInputEvent(
event: InputEvent,
{data, inputType, isComposing}: InputEventInit,
Expand Down
16 changes: 16 additions & 0 deletions src/event/eventMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const eventMap = {
EventType: 'InputEvent',
defaultInit: {bubbles: true, cancelable: true, composed: true},
},
blur: {
EventType: 'FocusEvent',
defaultInit: {bubbles: false, cancelable: false, composed: true},
},
click: {
EventType: 'PointerEvent',
defaultInit: {bubbles: true, cancelable: true, composed: true},
Expand All @@ -33,6 +37,18 @@ export const eventMap = {
EventType: 'MouseEvent',
defaultInit: {bubbles: true, cancelable: true, composed: true},
},
focus: {
EventType: 'FocusEvent',
defaultInit: {bubbles: false, cancelable: false, composed: true},
},
focusin: {
EventType: 'FocusEvent',
defaultInit: {bubbles: true, cancelable: false, composed: true},
},
focusout: {
EventType: 'FocusEvent',
defaultInit: {bubbles: true, cancelable: false, composed: true},
},
keydown: {
EventType: 'KeyboardEvent',
defaultInit: {bubbles: true, cancelable: true, composed: true},
Expand Down
2 changes: 2 additions & 0 deletions src/event/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type SpecificEventInit<E extends Event> = E extends InputEvent
? PointerEventInit
: E extends MouseEvent
? MouseEventInit
: E extends FocusEvent
? FocusEventInit
: E extends UIEvent
? UIEventInit
: EventInit
Expand Down
5 changes: 5 additions & 0 deletions src/setup/setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {patchFocus} from '../document/patchFocus'
import {prepareDocument} from '../document/prepareDocument'
import {dispatchEvent, dispatchUIEvent} from '../event'
import {defaultKeyMap as defaultKeyboardMap} from '../keyboard/keyMap'
Expand All @@ -7,6 +8,7 @@ import {
ApiLevel,
attachClipboardStubToView,
getDocumentFromNode,
getWindow,
setLevelRef,
wait,
} from '../utils'
Expand Down Expand Up @@ -82,6 +84,7 @@ export function createConfig(
export function setupMain(options: Options = {}) {
const config = createConfig(options)
prepareDocument(config.document)
patchFocus(getWindow(config.document).HTMLElement)

const view =
config.document.defaultView ?? /* istanbul ignore next */ globalThis.window
Expand All @@ -103,6 +106,8 @@ export function setupDirect(
) {
const config = createConfig(options, defaultOptionsDirect, node)
prepareDocument(config.document)
patchFocus(getWindow(config.document).HTMLElement)

const system = pointerState ?? keyboardState ?? new System()

return {
Expand Down
52 changes: 35 additions & 17 deletions tests/_helpers/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,22 @@ export type EventHandlers = {[k in keyof DocumentEventMap]?: EventListener}

const loggedEvents = [
...(Object.keys(eventMap) as Array<keyof typeof eventMap>),
'focus',
'focusin',
'focusout',
'blur',
'select',
] as const

/**
* Add listeners for logging events.
*/
export function addListeners(
element: Element,
element: Element | Element[],
{
eventHandlers = {},
}: {
eventHandlers?: EventHandlers
} = {},
) {
const elements = Array.isArray(element) ? element : [element]

type CallData = {
event: Event
elementDisplayName: string
Expand All @@ -59,14 +57,16 @@ export function addListeners(

const generalListener = mocks.fn(eventHandler).mockName('eventListener')

for (const eventType of loggedEvents) {
addEventListener(element, eventType, (...args) => {
generalListener(...args)
eventHandlers[eventType]?.(...args)
})
}
for (const el of elements) {
for (const eventType of loggedEvents) {
addEventListener(el, eventType, (...args) => {
generalListener(...args)
eventHandlers[eventType]?.(...args)
})
}

addEventListener(element, 'submit', e => e.preventDefault())
addEventListener(el, 'submit', e => e.preventDefault())
}

return {
clearEventCalls,
Expand Down Expand Up @@ -132,16 +132,14 @@ export function addListeners(
.join('\n')
.trim()

const displayNames = elements.map(el => getElementDisplayName(el)).join(',')
if (eventCalls.length) {
return {
snapshot: [
`Events fired on: ${getElementDisplayName(element)}`,
eventCalls,
].join('\n\n'),
snapshot: [`Events fired on: ${displayNames}`, eventCalls].join('\n\n'),
}
} else {
return {
snapshot: `No events were fired on: ${getElementDisplayName(element)}`,
snapshot: `No events were fired on: ${displayNames}`,
}
}
}
Expand Down Expand Up @@ -174,6 +172,10 @@ function isKeyboardEvent(event: Event): event is KeyboardEvent {
)
}

function isFocusEvent(event: Event): event is FocusEvent {
return event.constructor.name === 'FocusEvent'
}

function isPointerEvent(event: Event): event is PointerEvent {
return event.type.startsWith('pointer')
}
Expand Down Expand Up @@ -241,6 +243,22 @@ function getEventLabel(event: Event) {
return getMouseButtonName(event.button) ?? `button${event.button}`
} else if (isKeyboardEvent(event)) {
return event.key === ' ' ? 'Space' : event.key
} else if (isFocusEvent(event)) {
const direction =
event.type === 'focus' || event.type === 'focusin' ? '←' : 'β†’'
let label
if (
!event.relatedTarget ||
// Jsdom sets `relatedTarget` to `Document` on blur/focusout
('nodeType' in event.relatedTarget && event.relatedTarget.nodeType === 9)
) {
label = 'null'
} else if (isElement(event.relatedTarget)) {
label = getElementDisplayName(event.relatedTarget)
} else {
label = event.relatedTarget.constructor.name
}
return `${direction} ${label}`
}
}

Expand Down
10 changes: 3 additions & 7 deletions tests/_helpers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,9 @@ export function render<Elements extends Element | Element[] = HTMLElement>(
return {
element: div.firstChild as ElementsArray[0],
elements: div.children as ElementsCollection,
// for single elements add the listeners to the element for capturing non-bubbling events
...addListeners(
div.children.length === 1 ? (div.firstChild as Element) : div,
{
eventHandlers,
},
),
...addListeners(Array.from(div.children), {
eventHandlers,
}),
xpathNode: <NodeType extends Node = HTMLElement>(xpath: string) =>
assertSingleNodeFromXPath(xpath, div) as NodeType,
}
Expand Down
2 changes: 2 additions & 0 deletions tests/document/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
setUISelection,
} from '#src/document'
import {prepareDocument} from '#src/document/prepareDocument'
import {patchFocus} from '#src/document/patchFocus'

function prepare(element: Element) {
patchFocus(globalThis.window.HTMLElement)
prepareDocument(element.ownerDocument)
// safe to call multiple times
prepareDocument(element.ownerDocument)
Expand Down
62 changes: 62 additions & 0 deletions tests/document/patchFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {patchFocus, restoreFocus} from '#src/document/patchFocus'
import {isJsdomEnv, render} from '#testHelpers'

beforeAll(() => {
patchFocus(globalThis.window.HTMLElement)
return () => restoreFocus(globalThis.window.HTMLElement)
})

test('dispatch focus events', () => {
const {
elements: [a, b],
getEventSnapshot,
} = render(`<input id="a"/><input id="b"/>`, {focus: false})

a.focus()
b.focus()
a.blur()
b.blur()

expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input#a[value=""],input#b[value=""]
input#a[value=""] - focus: ← null
input#a[value=""] - focusin: ← null
input#a[value=""] - blur: β†’ input#b[value=""]
input#a[value=""] - focusout: β†’ input#b[value=""]
input#b[value=""] - focus: ← input#a[value=""]
input#b[value=""] - focusin: ← input#a[value=""]
input#b[value=""] - blur: β†’ null
input#b[value=""] - focusout: β†’ null
`)
})

test('`focus` handler can prevent subsequent `focusin`', () => {
const {element, getEventSnapshot} = render(`<input/>`, {focus: false})

element.addEventListener('focus', () => {
element.blur()
})

element.focus()

if (isJsdomEnv()) {
// The unpatched focus in Jsdom behaves differently than the browser
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value=""]
input[value=""] - focus: ← null
input[value=""] - blur: β†’ null
input[value=""] - focusout: β†’ null
input[value=""] - focusin: ← null
`)
} else {
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value=""]
input[value=""] - focus: ← null
input[value=""] - blur: β†’ null
input[value=""] - focusout: β†’ null
`)
}
})
Loading

0 comments on commit 1ed8b15

Please sign in to comment.