Skip to content

Commit caf0851

Browse files
authored
feat(useEventListener): make all parameters arrayable and reactive (#4486)
1 parent a84fa96 commit caf0851

File tree

2 files changed

+168
-56
lines changed

2 files changed

+168
-56
lines changed

packages/core/useEventListener/index.test.ts

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { Fn } from '@vueuse/shared'
22
import type { MockInstance } from 'vitest'
33
import type { Ref } from 'vue'
4-
import { noop } from '@vueuse/shared'
54
import { beforeEach, describe, expect, it, vi } from 'vitest'
6-
import { effectScope, nextTick, ref } from 'vue'
5+
import { computed, effectScope, nextTick, ref } from 'vue'
76
import { useEventListener } from '.'
87

98
describe('useEventListener', () => {
@@ -167,7 +166,6 @@ describe('useEventListener', () => {
167166
await nextTick()
168167

169168
expect(listener).toHaveBeenCalledTimes(0)
170-
expect(useEventListener(null, 'click', listener)).toBe(noop)
171169
})
172170

173171
function getTargetName(useTarget: boolean) {
@@ -209,12 +207,12 @@ describe('useEventListener', () => {
209207

210208
it(`should ${getTargetName(useTarget)} auto stop listening event`, async () => {
211209
const scope = effectScope()
212-
await scope.run(async () => {
210+
scope.run(async () => {
213211
// @ts-expect-error mock different args
214212
useEventListener(...getArgs(useTarget))
215213
})
216214

217-
await scope.stop()
215+
scope.stop()
218216

219217
trigger(useTarget)
220218

@@ -228,6 +226,119 @@ describe('useEventListener', () => {
228226
testTarget(true)
229227
})
230228

229+
describe('useEventListener - multiple targets', () => {
230+
it('should accept an array ref of DOM elements', async () => {
231+
const listener = vi.fn()
232+
const el1 = document.createElement('button')
233+
const el2 = document.createElement('button')
234+
const arrayRef = computed(() => [el1, el2])
235+
236+
useEventListener(arrayRef, 'click', listener)
237+
await nextTick()
238+
239+
el1.dispatchEvent(new Event('click'))
240+
el2.dispatchEvent(new Event('click'))
241+
expect(listener).toHaveBeenCalledTimes(2)
242+
})
243+
244+
it('should accept a getter returning multiple targets', async () => {
245+
const listener = vi.fn()
246+
const el1 = document.createElement('div')
247+
const el2 = document.createElement('div')
248+
const active = ref(true)
249+
250+
useEventListener(() => active.value ? [el1, el2] : [], 'mousedown', listener)
251+
await nextTick()
252+
253+
el1.dispatchEvent(new Event('mousedown'))
254+
el2.dispatchEvent(new Event('mousedown'))
255+
expect(listener).toHaveBeenCalledTimes(2)
256+
257+
// disable
258+
active.value = false
259+
await nextTick()
260+
el1.dispatchEvent(new Event('mousedown'))
261+
el2.dispatchEvent(new Event('mousedown'))
262+
// events should no longer trigger
263+
expect(listener).toHaveBeenCalledTimes(2)
264+
})
265+
266+
it('should accept an array of DOM elements + multiple events', async () => {
267+
const listener = vi.fn()
268+
const el1 = document.createElement('button')
269+
const el2 = document.createElement('button')
270+
const arrayRef = computed(() => [el1, el2])
271+
272+
useEventListener(arrayRef, ['click', 'hover'], listener)
273+
await nextTick()
274+
275+
el1.dispatchEvent(new Event('click'))
276+
el2.dispatchEvent(new Event('click'))
277+
el1.dispatchEvent(new Event('hover'))
278+
el2.dispatchEvent(new Event('hover'))
279+
expect(listener).toHaveBeenCalledTimes(4)
280+
})
281+
282+
it('should accept a getter returning multiple targets + multiple events', async () => {
283+
const listener = vi.fn()
284+
const el1 = document.createElement('div')
285+
const el2 = document.createElement('div')
286+
const active = ref(true)
287+
288+
useEventListener(() => active.value ? [el1, el2] : [], ['mousedown', 'click'], listener)
289+
await nextTick()
290+
291+
el1.dispatchEvent(new Event('mousedown'))
292+
el2.dispatchEvent(new Event('mousedown'))
293+
el1.dispatchEvent(new Event('click'))
294+
el2.dispatchEvent(new Event('click'))
295+
expect(listener).toHaveBeenCalledTimes(4)
296+
297+
// disable
298+
active.value = false
299+
await nextTick()
300+
el1.dispatchEvent(new Event('mousedown'))
301+
el2.dispatchEvent(new Event('mousedown'))
302+
el1.dispatchEvent(new Event('click'))
303+
el2.dispatchEvent(new Event('click'))
304+
// events should no longer trigger
305+
expect(listener).toHaveBeenCalledTimes(4)
306+
})
307+
308+
it('should react to target + event + function changes properly', async () => {
309+
const listener1 = vi.fn()
310+
const listener2 = vi.fn()
311+
const el1 = document.createElement('div')
312+
const el2 = document.createElement('div')
313+
const els = ref([el1])
314+
const events = ref(['click'])
315+
const listeners = ref([listener1])
316+
317+
useEventListener(els, events, listeners)
318+
el1.dispatchEvent(new Event('click'))
319+
els.value = [el2]
320+
await nextTick()
321+
el1.dispatchEvent(new Event('click'))
322+
el2.dispatchEvent(new Event('click'))
323+
events.value = ['mousedown']
324+
await nextTick()
325+
el1.dispatchEvent(new Event('click'))
326+
el2.dispatchEvent(new Event('click'))
327+
el2.dispatchEvent(new Event('mousedown'))
328+
els.value = [el1, el2]
329+
events.value = ['click', 'mousedown']
330+
listeners.value = [listener1, listener2]
331+
await nextTick()
332+
el1.dispatchEvent(new Event('click'))
333+
el2.dispatchEvent(new Event('click'))
334+
el1.dispatchEvent(new Event('mousedown'))
335+
el2.dispatchEvent(new Event('mousedown'))
336+
337+
expect(listener1).toHaveBeenCalledTimes(7)
338+
expect(listener2).toHaveBeenCalledTimes(4)
339+
})
340+
})
341+
231342
it('should auto re-register', async () => {
232343
const target = ref()
233344
const listener = vi.fn()

packages/core/useEventListener/index.ts

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { Arrayable, Fn, MaybeRefOrGetter } from '@vueuse/shared'
2-
import type { MaybeElementRef } from '../unrefElement'
3-
import { isObject, noop, toArray, tryOnScopeDispose } from '@vueuse/shared'
4-
import { toValue, watch } from 'vue'
1+
import type { Arrayable, Fn, MaybeRef, MaybeRefOrGetter } from '@vueuse/shared'
2+
import { isObject, toArray, tryOnScopeDispose, watchImmediate } from '@vueuse/shared'
3+
// eslint-disable-next-line no-restricted-imports -- We specifically need to use unref here to distinguish between callbacks
4+
import { computed, toValue, unref } from 'vue'
55
import { defaultWindow } from '../_configurable'
66
import { unrefElement } from '../unrefElement'
77

@@ -27,9 +27,10 @@ export interface GeneralEventListener<E = Event> {
2727
* @param listener
2828
* @param options
2929
*/
30+
// @ts-expect-error - TypeScript gets confused with this and can't infer the correct overload with Parameters<...>
3031
export function useEventListener<E extends keyof WindowEventMap>(
31-
event: Arrayable<E>,
32-
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
32+
event: MaybeRefOrGetter<Arrayable<E>>,
33+
listener: MaybeRef<Arrayable<(this: Window, ev: WindowEventMap[E]) => any>>,
3334
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
3435
): Fn
3536

@@ -46,8 +47,8 @@ export function useEventListener<E extends keyof WindowEventMap>(
4647
*/
4748
export function useEventListener<E extends keyof WindowEventMap>(
4849
target: Window,
49-
event: Arrayable<E>,
50-
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
50+
event: MaybeRefOrGetter<Arrayable<E>>,
51+
listener: MaybeRef<Arrayable<(this: Window, ev: WindowEventMap[E]) => any>>,
5152
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
5253
): Fn
5354

@@ -64,8 +65,8 @@ export function useEventListener<E extends keyof WindowEventMap>(
6465
*/
6566
export function useEventListener<E extends keyof DocumentEventMap>(
6667
target: DocumentOrShadowRoot,
67-
event: Arrayable<E>,
68-
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
68+
event: MaybeRefOrGetter<Arrayable<E>>,
69+
listener: MaybeRef<Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>>,
6970
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
7071
): Fn
7172

@@ -81,10 +82,10 @@ export function useEventListener<E extends keyof DocumentEventMap>(
8182
* @param options
8283
*/
8384
export function useEventListener<E extends keyof HTMLElementEventMap>(
84-
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
85-
event: Arrayable<E>,
86-
listener: (this: HTMLElement, ev: HTMLElementEventMap[E]) => any,
87-
options?: boolean | AddEventListenerOptions
85+
target: MaybeRefOrGetter<Arrayable<HTMLElement> | null | undefined>,
86+
event: MaybeRefOrGetter<Arrayable<E>>,
87+
listener: MaybeRef<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
88+
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
8889
): () => void
8990

9091
/**
@@ -99,9 +100,9 @@ export function useEventListener<E extends keyof HTMLElementEventMap>(
99100
* @param options
100101
*/
101102
export function useEventListener<Names extends string, EventType = Event>(
102-
target: MaybeRefOrGetter<InferEventTarget<Names> | null | undefined>,
103-
event: Arrayable<Names>,
104-
listener: Arrayable<GeneralEventListener<EventType>>,
103+
target: MaybeRefOrGetter<Arrayable<InferEventTarget<Names>> | null | undefined>,
104+
event: MaybeRefOrGetter<Arrayable<Names>>,
105+
listener: MaybeRef<Arrayable<GeneralEventListener<EventType>>>,
105106
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
106107
): Fn
107108

@@ -117,67 +118,67 @@ export function useEventListener<Names extends string, EventType = Event>(
117118
* @param options
118119
*/
119120
export function useEventListener<EventType = Event>(
120-
target: MaybeRefOrGetter<EventTarget | null | undefined>,
121-
event: Arrayable<string>,
122-
listener: Arrayable<GeneralEventListener<EventType>>,
121+
target: MaybeRefOrGetter<Arrayable<EventTarget> | null | undefined>,
122+
event: MaybeRefOrGetter<Arrayable<string>>,
123+
listener: MaybeRef<Arrayable<GeneralEventListener<EventType>>>,
123124
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
124125
): Fn
125126

126-
export function useEventListener(...args: any[]) {
127-
let target: MaybeRefOrGetter<EventTarget> | undefined
128-
let events: Arrayable<string>
129-
let listeners: Arrayable<Function>
130-
let options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined
131-
132-
if (typeof args[0] === 'string' || Array.isArray(args[0])) {
133-
[events, listeners, options] = args
134-
target = defaultWindow
135-
}
136-
else {
137-
[target, events, listeners, options] = args
138-
}
139-
140-
if (!target)
141-
return noop
142-
143-
events = toArray(events)
144-
listeners = toArray(listeners)
145-
127+
export function useEventListener(...args: Parameters<typeof useEventListener>) {
146128
const cleanups: Function[] = []
147129
const cleanup = () => {
148130
cleanups.forEach(fn => fn())
149131
cleanups.length = 0
150132
}
151133

152-
const register = (el: any, event: string, listener: any, options: any) => {
134+
const register = (
135+
el: EventTarget,
136+
event: string,
137+
listener: any,
138+
options: boolean | AddEventListenerOptions | undefined,
139+
) => {
153140
el.addEventListener(event, listener, options)
154141
return () => el.removeEventListener(event, listener, options)
155142
}
156143

157-
const stopWatch = watch(
158-
() => [unrefElement(target as unknown as MaybeElementRef), toValue(options)],
159-
([el, options]) => {
144+
const firstParamTargets = computed(() => {
145+
const test = toArray(toValue(args[0])).filter(e => e != null)
146+
return test.every(e => typeof e !== 'string') ? test : undefined
147+
})
148+
149+
const stopWatch = watchImmediate(
150+
() => [
151+
firstParamTargets.value?.map(e => unrefElement(e as never)) ?? [defaultWindow].filter(e => e != null),
152+
toArray(toValue(firstParamTargets.value ? args[1] : args[0])) as unknown as string[],
153+
toArray(unref(firstParamTargets.value ? args[2] : args[1])) as Function[],
154+
// @ts-expect-error - TypeScript gets the correct types, but somehow still complains
155+
toValue(firstParamTargets.value ? args[3] : args[2]) as boolean | AddEventListenerOptions | undefined,
156+
] as const,
157+
([raw_targets, raw_events, raw_listeners, raw_options]) => {
160158
cleanup()
161-
if (!el)
159+
160+
if (!raw_targets?.length || !raw_events?.length || !raw_listeners?.length)
162161
return
163162

164163
// create a clone of options, to avoid it being changed reactively on removal
165-
const optionsClone = isObject(options) ? { ...options } : options
164+
const optionsClone = isObject(raw_options) ? { ...raw_options } : raw_options
166165
cleanups.push(
167-
...(events as string[]).flatMap((event) => {
168-
return (listeners as Function[]).map(listener => register(el, event, listener, optionsClone))
169-
}),
166+
...raw_targets.flatMap(el =>
167+
raw_events.flatMap(event =>
168+
raw_listeners.map(listener => register(el, event, listener, optionsClone)),
169+
),
170+
),
170171
)
171172
},
172-
{ immediate: true, flush: 'post' },
173+
{ flush: 'post' },
173174
)
174175

175176
const stop = () => {
176177
stopWatch()
177178
cleanup()
178179
}
179180

180-
tryOnScopeDispose(stop)
181+
tryOnScopeDispose(cleanup)
181182

182183
return stop
183184
}

0 commit comments

Comments
 (0)