Skip to content

Commit 214fd03

Browse files
authored
fix(upload): fix order of events (#847)
1 parent 4720ac2 commit 214fd03

File tree

11 files changed

+185
-42
lines changed

11 files changed

+185
-42
lines changed

src/event/behavior/click.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {blur, cloneEvent, focus, isElementType, isFocusable} from '../../utils'
2+
import {dispatchEvent} from '../dispatchEvent'
3+
import {behavior} from './registry'
4+
5+
behavior.click = (event, target, config) => {
6+
const control = target.closest('label')?.control
7+
if (control) {
8+
return () => {
9+
if (isFocusable(control)) {
10+
focus(control)
11+
}
12+
dispatchEvent(config, control, cloneEvent(event))
13+
}
14+
} else if (isElementType(target, 'input', {type: 'file'})) {
15+
return () => {
16+
// blur fires when the file selector pops up
17+
blur(target)
18+
19+
target.dispatchEvent(new Event('fileDialog'))
20+
21+
// focus fires after the file selector has been closed
22+
focus(target)
23+
}
24+
}
25+
}

src/event/behavior/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import './click'
2+
3+
export {behavior} from './registry'
4+
export type {BehaviorPlugin} from './registry'

src/event/behavior/registry.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Config} from '../../setup'
2+
import {EventType} from '../types'
3+
4+
export interface BehaviorPlugin<Type extends EventType> {
5+
(
6+
event: DocumentEventMap[Type],
7+
target: Element,
8+
config: Config,
9+
): // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
10+
void | (() => void)
11+
}
12+
13+
export const behavior: {
14+
[Type in EventType]?: BehaviorPlugin<Type>
15+
} = {}

src/event/dispatchEvent.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {Config} from '../setup'
2+
import {EventType} from './types'
3+
import {behavior, BehaviorPlugin} from './behavior'
4+
5+
export function dispatchEvent(config: Config, target: Element, event: Event) {
6+
const type = event.type as EventType
7+
const behaviorImplementation = (
8+
behavior[type] as BehaviorPlugin<EventType> | undefined
9+
)?.(event, target, config)
10+
11+
if (behaviorImplementation) {
12+
event.preventDefault()
13+
let defaultPrevented = false
14+
Object.defineProperty(event, 'defaultPrevented', {
15+
get: () => defaultPrevented,
16+
})
17+
Object.defineProperty(event, 'preventDefault', {
18+
value: () => {
19+
defaultPrevented = event.cancelable
20+
},
21+
})
22+
23+
target.dispatchEvent(event)
24+
25+
if (!defaultPrevented as boolean) {
26+
behaviorImplementation()
27+
}
28+
29+
return !defaultPrevented
30+
}
31+
32+
return target.dispatchEvent(event)
33+
}

src/event/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Config} from '../setup'
22
import {getUIEventModifiers} from '../utils'
33
import {createEvent, EventTypeInit} from './createEvent'
4+
import {dispatchEvent} from './dispatchEvent'
45
import {isKeyboardEvent, isMouseEvent} from './eventTypes'
56
import {EventType, PointerCoords} from './types'
67
import {wrapEvent} from './wrapEvent'
@@ -22,7 +23,7 @@ export function dispatchUIEvent<K extends EventType>(
2223

2324
const event = createEvent(type, target, init)
2425

25-
return wrapEvent(() => target.dispatchEvent(event), target)
26+
return wrapEvent(() => dispatchEvent(config, target, event), target)
2627
}
2728

2829
export function bindDispatchUIEvent(config: Config) {

src/pointer/pointerPress.ts

+1-14
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
focus,
77
isDisabled,
88
isElementType,
9-
isFocusable,
109
setLevelRef,
1110
} from '../utils'
1211
import {getUIValue, setUISelection} from '../document'
@@ -231,16 +230,11 @@ function up(
231230

232231
const canClick = pointerType !== 'mouse' || button === 'primary'
233232
if (canClick && target === pressed.downTarget) {
234-
const unpreventedClick = fire('click')
233+
fire('click')
235234

236235
if (clickCount === 2) {
237236
fire('dblclick')
238237
}
239-
if (unpreventedClick) {
240-
clickDefaultBehavior({
241-
target,
242-
})
243-
}
244238
}
245239
}
246240
}
@@ -367,10 +361,3 @@ function getTextRange(
367361
(text.substr(pos).match(/^[^\r\n]*/) as RegExpMatchArray)[0].length,
368362
]
369363
}
370-
371-
function clickDefaultBehavior({target}: {target: Element}) {
372-
const control = target.closest('label')?.control
373-
if (control && isFocusable(control)) {
374-
focus(control)
375-
}
376-
}

src/utility/upload.ts

+17-23
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
blur,
3-
createFileList,
4-
focus,
5-
isDisabled,
6-
isElementType,
7-
setFiles,
8-
} from '../utils'
1+
import {createFileList, isDisabled, isElementType, setFiles} from '../utils'
92
import {Config, Instance} from '../setup'
103

114
export interface uploadInit {
@@ -28,30 +21,31 @@ export async function upload(
2821
}
2922
if (isDisabled(element)) return
3023

31-
await this.click(element)
32-
3324
const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles])
3425
.filter(
3526
file => !this[Config].applyAccept || isAcceptableFile(file, input.accept),
3627
)
3728
.slice(0, input.multiple ? undefined : 1)
3829

39-
// blur fires when the file selector pops up
40-
blur(input)
41-
// focus fires when they make their selection
42-
focus(input)
30+
const fileDialog = () => {
31+
// do not fire an input event if the file selection does not change
32+
if (
33+
files.length === input.files?.length &&
34+
files.every((f, i) => f === input.files?.item(i))
35+
) {
36+
return
37+
}
4338

44-
// do not fire an input event if the file selection does not change
45-
if (
46-
files.length === input.files?.length &&
47-
files.every((f, i) => f === input.files?.item(i))
48-
) {
49-
return
39+
setFiles(input, createFileList(files))
40+
this.dispatchUIEvent(input, 'input')
41+
this.dispatchUIEvent(input, 'change')
5042
}
5143

52-
setFiles(input, createFileList(files))
53-
this.dispatchUIEvent(input, 'input')
54-
this.dispatchUIEvent(input, 'change')
44+
input.addEventListener('fileDialog', fileDialog)
45+
46+
await this.click(element)
47+
48+
input.removeEventListener('fileDialog', fileDialog)
5549
}
5650

5751
function isAcceptableFile(file: File, accept: string) {

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export * from './keyboard/getUIEventModifiers'
3333

3434
export * from './keyDef/readNextDescriptor'
3535

36+
export * from './misc/cloneEvent'
3637
export * from './misc/eventWrapper'
3738
export * from './misc/findClosest'
3839
export * from './misc/getDocumentFromNode'

src/utils/misc/cloneEvent.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
interface EventConstructor<E extends Event> {
2+
new (type: string, init: EventInit): E
3+
}
4+
5+
export function cloneEvent<E extends Event>(event: E) {
6+
return new (event.constructor as EventConstructor<E>)(event.type, event)
7+
}

tests/event/dispatchEvent.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {dispatchUIEvent} from '#src/event'
2+
import {behavior, BehaviorPlugin} from '#src/event/behavior'
3+
import {createConfig} from '#src/setup/setup'
4+
import {setup} from '#testHelpers/utils'
5+
6+
jest.mock('#src/event/behavior', () => ({
7+
behavior: {
8+
click: jest.fn(),
9+
},
10+
}))
11+
12+
const mockPlugin = behavior.click as jest.MockedFunction<
13+
BehaviorPlugin<'click'>
14+
>
15+
16+
afterEach(() => {
17+
jest.clearAllMocks()
18+
})
19+
20+
test('keep default behavior', () => {
21+
const {element} = setup(`<input type="checkbox"/>`)
22+
23+
dispatchUIEvent(createConfig(), element, 'click')
24+
25+
expect(mockPlugin).toBeCalledTimes(1)
26+
expect(element).toBeChecked()
27+
})
28+
29+
test('replace default behavior', () => {
30+
const {element} = setup(`<input type="checkbox"/>`)
31+
32+
const mockBehavior = jest.fn()
33+
mockPlugin.mockImplementationOnce(() => mockBehavior)
34+
35+
dispatchUIEvent(createConfig(), element, 'click')
36+
37+
expect(mockPlugin).toBeCalledTimes(1)
38+
expect(element).not.toBeChecked()
39+
expect(mockBehavior).toBeCalled()
40+
})
41+
42+
test('prevent replaced default behavior', () => {
43+
const {element} = setup(`<input type="checkbox"/>`)
44+
element.addEventListener('click', e => {
45+
expect(e).toHaveProperty('defaultPrevented', false)
46+
e.preventDefault()
47+
expect(e).toHaveProperty('defaultPrevented', true)
48+
})
49+
50+
const mockBehavior = jest.fn()
51+
mockPlugin.mockImplementationOnce(() => mockBehavior)
52+
53+
dispatchUIEvent(createConfig(), element, 'click')
54+
55+
expect(mockPlugin).toBeCalledTimes(1)
56+
expect(element).not.toBeChecked()
57+
expect(mockBehavior).not.toBeCalled()
58+
})

tests/utility/upload.ts

+22-4
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@ test('change file input', async () => {
2929
input[value=""] - pointerup
3030
input[value=""] - mouseup: primary
3131
input[value=""] - click: primary
32+
"{CURSOR}" -> "C:\\\\fakepath\\\\hello.png{CURSOR}C:\\\\fakepath\\\\hello.png"
3233
input[value=""] - blur
3334
input[value=""] - focusout
34-
input[value=""] - focus
35-
input[value=""] - focusin
3635
input[value="C:\\\\fakepath\\\\hello.png"] - input
3736
input[value="C:\\\\fakepath\\\\hello.png"] - change
37+
input[value="C:\\\\fakepath\\\\hello.png"] - focus
38+
input[value="C:\\\\fakepath\\\\hello.png"] - focusin
3839
`)
3940
})
4041

@@ -63,15 +64,32 @@ test('relay click/upload on label to file input', async () => {
6364
label[for="element"] - pointerup
6465
label[for="element"] - mouseup: primary
6566
label[for="element"] - click: primary
66-
input#element[value=""] - click: primary
6767
input#element[value=""] - focusin
68+
input#element[value=""] - click: primary
6869
input#element[value=""] - focusout
69-
input#element[value=""] - focusin
7070
input#element[value="C:\\\\fakepath\\\\hello.png"] - input
7171
input#element[value="C:\\\\fakepath\\\\hello.png"] - change
72+
input#element[value="C:\\\\fakepath\\\\hello.png"] - focusin
7273
`)
7374
})
7475

76+
test('prevent file dialog per click event handler', async () => {
77+
const file = new File(['hello'], 'hello.png', {type: 'image/png'})
78+
79+
const {
80+
elements: [label, input],
81+
eventWasFired,
82+
} = setup<[HTMLLabelElement]>(`
83+
<label for="element">Element</label>
84+
<input type="file" id="element" />
85+
`)
86+
input.addEventListener('click', e => e.preventDefault())
87+
88+
await userEvent.upload(label, file)
89+
90+
expect(eventWasFired('input')).toBe(false)
91+
})
92+
7593
test('upload multiple files', async () => {
7694
const files = [
7795
new File(['hello'], 'hello.png', {type: 'image/png'}),

0 commit comments

Comments
 (0)