Skip to content

Commit 1ed8b15

Browse files
authored
feat: dispatch FocusEvent in hidden documents (#1252)
1 parent 63f7468 commit 1ed8b15

File tree

18 files changed

+327
-74
lines changed

18 files changed

+327
-74
lines changed

β€Žsrc/document/patchFocus.ts

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {dispatchDOMEvent} from '../event'
2+
import {getActiveElement} from '../utils'
3+
4+
const patched = Symbol('patched focus/blur methods')
5+
6+
declare global {
7+
interface HTMLElement {
8+
readonly [patched]?: Pick<HTMLElement, 'focus' | 'blur'>
9+
}
10+
}
11+
12+
export function patchFocus(HTMLElement: typeof globalThis['HTMLElement']) {
13+
if (HTMLElement.prototype[patched]) {
14+
return
15+
}
16+
17+
// eslint-disable-next-line @typescript-eslint/unbound-method
18+
const {focus, blur} = HTMLElement.prototype
19+
20+
Object.defineProperties(HTMLElement.prototype, {
21+
focus: {
22+
configurable: true,
23+
get: () => patchedFocus,
24+
},
25+
blur: {
26+
configurable: true,
27+
get: () => patchedBlur,
28+
},
29+
[patched]: {
30+
configurable: true,
31+
get: () => ({focus, blur}),
32+
},
33+
})
34+
35+
let activeCall: symbol
36+
37+
function patchedFocus(this: HTMLElement, options: FocusOptions) {
38+
if (this.ownerDocument.visibilityState !== 'hidden') {
39+
return focus.call(this, options)
40+
}
41+
42+
const blured = getActiveTarget(this.ownerDocument)
43+
if (blured === this) {
44+
return
45+
}
46+
47+
const thisCall = Symbol('focus call')
48+
activeCall = thisCall
49+
50+
if (blured) {
51+
blur.call(blured)
52+
dispatchDOMEvent(blured, 'blur', {relatedTarget: this})
53+
dispatchDOMEvent(blured, 'focusout', {
54+
relatedTarget: activeCall === thisCall ? this : null,
55+
})
56+
}
57+
if (activeCall === thisCall) {
58+
focus.call(this, options)
59+
dispatchDOMEvent(this, 'focus', {relatedTarget: blured})
60+
}
61+
if (activeCall === thisCall) {
62+
dispatchDOMEvent(this, 'focusin', {relatedTarget: blured})
63+
}
64+
}
65+
66+
function patchedBlur(this: HTMLElement) {
67+
if (this.ownerDocument.visibilityState !== 'hidden') {
68+
return blur.call(this)
69+
}
70+
71+
const blured = getActiveTarget(this.ownerDocument)
72+
if (blured !== this) {
73+
return
74+
}
75+
76+
const thisCall = Symbol('blur call')
77+
activeCall = thisCall
78+
79+
blur.call(this)
80+
dispatchDOMEvent(blured, 'blur', {relatedTarget: null})
81+
dispatchDOMEvent(blured, 'focusout', {relatedTarget: null})
82+
}
83+
}
84+
85+
function getActiveTarget(document: Document) {
86+
const active = getActiveElement(document)
87+
return active?.tagName === 'BODY' ? null : active
88+
}
89+
90+
export function restoreFocus(HTMLElement: typeof globalThis['HTMLElement']) {
91+
if (HTMLElement.prototype[patched]) {
92+
const {focus, blur} = HTMLElement.prototype[patched]
93+
Object.defineProperties(HTMLElement.prototype, {
94+
focus: {
95+
configurable: true,
96+
get: () => focus,
97+
},
98+
blur: {
99+
configurable: true,
100+
get: () => blur,
101+
},
102+
})
103+
}
104+
}

β€Žsrc/event/createEvent.ts

+8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface InterfaceMap {
1212
MouseEvent: {type: MouseEvent; init: MouseEventInit}
1313
PointerEvent: {type: PointerEvent; init: PointerEventInit}
1414
KeyboardEvent: {type: KeyboardEvent; init: KeyboardEventInit}
15+
FocusEvent: {type: FocusEvent; init: FocusEventInit}
1516
}
1617
type InterfaceNames = typeof eventMap[keyof typeof eventMap]['EventType']
1718
type Interface<k extends InterfaceNames> = k extends keyof InterfaceMap
@@ -25,6 +26,7 @@ const eventInitializer: {
2526
} = {
2627
ClipboardEvent: [initClipboardEvent],
2728
Event: [],
29+
FocusEvent: [initUIEvent, initFocusEvent],
2830
InputEvent: [initUIEvent, initInputEvent],
2931
MouseEvent: [initUIEvent, initUIEventModififiers, initMouseEvent],
3032
PointerEvent: [
@@ -117,6 +119,12 @@ function initClipboardEvent(
117119
})
118120
}
119121

122+
function initFocusEvent(event: FocusEvent, {relatedTarget}: FocusEventInit) {
123+
assignProps(event, {
124+
relatedTarget,
125+
})
126+
}
127+
120128
function initInputEvent(
121129
event: InputEvent,
122130
{data, inputType, isComposing}: InputEventInit,

β€Žsrc/event/eventMap.ts

+16
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const eventMap = {
99
EventType: 'InputEvent',
1010
defaultInit: {bubbles: true, cancelable: true, composed: true},
1111
},
12+
blur: {
13+
EventType: 'FocusEvent',
14+
defaultInit: {bubbles: false, cancelable: false, composed: true},
15+
},
1216
click: {
1317
EventType: 'PointerEvent',
1418
defaultInit: {bubbles: true, cancelable: true, composed: true},
@@ -33,6 +37,18 @@ export const eventMap = {
3337
EventType: 'MouseEvent',
3438
defaultInit: {bubbles: true, cancelable: true, composed: true},
3539
},
40+
focus: {
41+
EventType: 'FocusEvent',
42+
defaultInit: {bubbles: false, cancelable: false, composed: true},
43+
},
44+
focusin: {
45+
EventType: 'FocusEvent',
46+
defaultInit: {bubbles: true, cancelable: false, composed: true},
47+
},
48+
focusout: {
49+
EventType: 'FocusEvent',
50+
defaultInit: {bubbles: true, cancelable: false, composed: true},
51+
},
3652
keydown: {
3753
EventType: 'KeyboardEvent',
3854
defaultInit: {bubbles: true, cancelable: true, composed: true},

β€Žsrc/event/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type SpecificEventInit<E extends Event> = E extends InputEvent
2020
? PointerEventInit
2121
: E extends MouseEvent
2222
? MouseEventInit
23+
: E extends FocusEvent
24+
? FocusEventInit
2325
: E extends UIEvent
2426
? UIEventInit
2527
: EventInit

β€Žsrc/setup/setup.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {patchFocus} from '../document/patchFocus'
12
import {prepareDocument} from '../document/prepareDocument'
23
import {dispatchEvent, dispatchUIEvent} from '../event'
34
import {defaultKeyMap as defaultKeyboardMap} from '../keyboard/keyMap'
@@ -7,6 +8,7 @@ import {
78
ApiLevel,
89
attachClipboardStubToView,
910
getDocumentFromNode,
11+
getWindow,
1012
setLevelRef,
1113
wait,
1214
} from '../utils'
@@ -82,6 +84,7 @@ export function createConfig(
8284
export function setupMain(options: Options = {}) {
8385
const config = createConfig(options)
8486
prepareDocument(config.document)
87+
patchFocus(getWindow(config.document).HTMLElement)
8588

8689
const view =
8790
config.document.defaultView ?? /* istanbul ignore next */ globalThis.window
@@ -103,6 +106,8 @@ export function setupDirect(
103106
) {
104107
const config = createConfig(options, defaultOptionsDirect, node)
105108
prepareDocument(config.document)
109+
patchFocus(getWindow(config.document).HTMLElement)
110+
106111
const system = pointerState ?? keyboardState ?? new System()
107112

108113
return {

β€Žtests/_helpers/listeners.ts

+35-17
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,22 @@ export type EventHandlers = {[k in keyof DocumentEventMap]?: EventListener}
3333

3434
const loggedEvents = [
3535
...(Object.keys(eventMap) as Array<keyof typeof eventMap>),
36-
'focus',
37-
'focusin',
38-
'focusout',
39-
'blur',
4036
'select',
4137
] as const
4238

4339
/**
4440
* Add listeners for logging events.
4541
*/
4642
export function addListeners(
47-
element: Element,
43+
element: Element | Element[],
4844
{
4945
eventHandlers = {},
5046
}: {
5147
eventHandlers?: EventHandlers
5248
} = {},
5349
) {
50+
const elements = Array.isArray(element) ? element : [element]
51+
5452
type CallData = {
5553
event: Event
5654
elementDisplayName: string
@@ -59,14 +57,16 @@ export function addListeners(
5957

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

62-
for (const eventType of loggedEvents) {
63-
addEventListener(element, eventType, (...args) => {
64-
generalListener(...args)
65-
eventHandlers[eventType]?.(...args)
66-
})
67-
}
60+
for (const el of elements) {
61+
for (const eventType of loggedEvents) {
62+
addEventListener(el, eventType, (...args) => {
63+
generalListener(...args)
64+
eventHandlers[eventType]?.(...args)
65+
})
66+
}
6867

69-
addEventListener(element, 'submit', e => e.preventDefault())
68+
addEventListener(el, 'submit', e => e.preventDefault())
69+
}
7070

7171
return {
7272
clearEventCalls,
@@ -132,16 +132,14 @@ export function addListeners(
132132
.join('\n')
133133
.trim()
134134

135+
const displayNames = elements.map(el => getElementDisplayName(el)).join(',')
135136
if (eventCalls.length) {
136137
return {
137-
snapshot: [
138-
`Events fired on: ${getElementDisplayName(element)}`,
139-
eventCalls,
140-
].join('\n\n'),
138+
snapshot: [`Events fired on: ${displayNames}`, eventCalls].join('\n\n'),
141139
}
142140
} else {
143141
return {
144-
snapshot: `No events were fired on: ${getElementDisplayName(element)}`,
142+
snapshot: `No events were fired on: ${displayNames}`,
145143
}
146144
}
147145
}
@@ -174,6 +172,10 @@ function isKeyboardEvent(event: Event): event is KeyboardEvent {
174172
)
175173
}
176174

175+
function isFocusEvent(event: Event): event is FocusEvent {
176+
return event.constructor.name === 'FocusEvent'
177+
}
178+
177179
function isPointerEvent(event: Event): event is PointerEvent {
178180
return event.type.startsWith('pointer')
179181
}
@@ -241,6 +243,22 @@ function getEventLabel(event: Event) {
241243
return getMouseButtonName(event.button) ?? `button${event.button}`
242244
} else if (isKeyboardEvent(event)) {
243245
return event.key === ' ' ? 'Space' : event.key
246+
} else if (isFocusEvent(event)) {
247+
const direction =
248+
event.type === 'focus' || event.type === 'focusin' ? '←' : 'β†’'
249+
let label
250+
if (
251+
!event.relatedTarget ||
252+
// Jsdom sets `relatedTarget` to `Document` on blur/focusout
253+
('nodeType' in event.relatedTarget && event.relatedTarget.nodeType === 9)
254+
) {
255+
label = 'null'
256+
} else if (isElement(event.relatedTarget)) {
257+
label = getElementDisplayName(event.relatedTarget)
258+
} else {
259+
label = event.relatedTarget.constructor.name
260+
}
261+
return `${direction} ${label}`
244262
}
245263
}
246264

β€Žtests/_helpers/setup.ts

+3-7
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,9 @@ export function render<Elements extends Element | Element[] = HTMLElement>(
6464
return {
6565
element: div.firstChild as ElementsArray[0],
6666
elements: div.children as ElementsCollection,
67-
// for single elements add the listeners to the element for capturing non-bubbling events
68-
...addListeners(
69-
div.children.length === 1 ? (div.firstChild as Element) : div,
70-
{
71-
eventHandlers,
72-
},
73-
),
67+
...addListeners(Array.from(div.children), {
68+
eventHandlers,
69+
}),
7470
xpathNode: <NodeType extends Node = HTMLElement>(xpath: string) =>
7571
assertSingleNodeFromXPath(xpath, div) as NodeType,
7672
}

β€Žtests/document/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
setUISelection,
77
} from '#src/document'
88
import {prepareDocument} from '#src/document/prepareDocument'
9+
import {patchFocus} from '#src/document/patchFocus'
910

1011
function prepare(element: Element) {
12+
patchFocus(globalThis.window.HTMLElement)
1113
prepareDocument(element.ownerDocument)
1214
// safe to call multiple times
1315
prepareDocument(element.ownerDocument)

β€Žtests/document/patchFocus.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {patchFocus, restoreFocus} from '#src/document/patchFocus'
2+
import {isJsdomEnv, render} from '#testHelpers'
3+
4+
beforeAll(() => {
5+
patchFocus(globalThis.window.HTMLElement)
6+
return () => restoreFocus(globalThis.window.HTMLElement)
7+
})
8+
9+
test('dispatch focus events', () => {
10+
const {
11+
elements: [a, b],
12+
getEventSnapshot,
13+
} = render(`<input id="a"/><input id="b"/>`, {focus: false})
14+
15+
a.focus()
16+
b.focus()
17+
a.blur()
18+
b.blur()
19+
20+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
21+
Events fired on: input#a[value=""],input#b[value=""]
22+
23+
input#a[value=""] - focus: ← null
24+
input#a[value=""] - focusin: ← null
25+
input#a[value=""] - blur: β†’ input#b[value=""]
26+
input#a[value=""] - focusout: β†’ input#b[value=""]
27+
input#b[value=""] - focus: ← input#a[value=""]
28+
input#b[value=""] - focusin: ← input#a[value=""]
29+
input#b[value=""] - blur: β†’ null
30+
input#b[value=""] - focusout: β†’ null
31+
`)
32+
})
33+
34+
test('`focus` handler can prevent subsequent `focusin`', () => {
35+
const {element, getEventSnapshot} = render(`<input/>`, {focus: false})
36+
37+
element.addEventListener('focus', () => {
38+
element.blur()
39+
})
40+
41+
element.focus()
42+
43+
if (isJsdomEnv()) {
44+
// The unpatched focus in Jsdom behaves differently than the browser
45+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
46+
Events fired on: input[value=""]
47+
48+
input[value=""] - focus: ← null
49+
input[value=""] - blur: β†’ null
50+
input[value=""] - focusout: β†’ null
51+
input[value=""] - focusin: ← null
52+
`)
53+
} else {
54+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
55+
Events fired on: input[value=""]
56+
57+
input[value=""] - focus: ← null
58+
input[value=""] - blur: β†’ null
59+
input[value=""] - focusout: β†’ null
60+
`)
61+
}
62+
})

0 commit comments

Comments
Β (0)