Skip to content

Commit

Permalink
feat(useEventListener): make all parameters arrayable and reactive (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
ferferga authored Jan 14, 2025
1 parent a84fa96 commit caf0851
Show file tree
Hide file tree
Showing 2 changed files with 168 additions and 56 deletions.
121 changes: 116 additions & 5 deletions packages/core/useEventListener/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { Fn } from '@vueuse/shared'
import type { MockInstance } from 'vitest'
import type { Ref } from 'vue'
import { noop } from '@vueuse/shared'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import { computed, effectScope, nextTick, ref } from 'vue'
import { useEventListener } from '.'

describe('useEventListener', () => {
Expand Down Expand Up @@ -167,7 +166,6 @@ describe('useEventListener', () => {
await nextTick()

expect(listener).toHaveBeenCalledTimes(0)
expect(useEventListener(null, 'click', listener)).toBe(noop)
})

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

it(`should ${getTargetName(useTarget)} auto stop listening event`, async () => {
const scope = effectScope()
await scope.run(async () => {
scope.run(async () => {
// @ts-expect-error mock different args
useEventListener(...getArgs(useTarget))
})

await scope.stop()
scope.stop()

trigger(useTarget)

Expand All @@ -228,6 +226,119 @@ describe('useEventListener', () => {
testTarget(true)
})

describe('useEventListener - multiple targets', () => {
it('should accept an array ref of DOM elements', async () => {
const listener = vi.fn()
const el1 = document.createElement('button')
const el2 = document.createElement('button')
const arrayRef = computed(() => [el1, el2])

useEventListener(arrayRef, 'click', listener)
await nextTick()

el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
expect(listener).toHaveBeenCalledTimes(2)
})

it('should accept a getter returning multiple targets', async () => {
const listener = vi.fn()
const el1 = document.createElement('div')
const el2 = document.createElement('div')
const active = ref(true)

useEventListener(() => active.value ? [el1, el2] : [], 'mousedown', listener)
await nextTick()

el1.dispatchEvent(new Event('mousedown'))
el2.dispatchEvent(new Event('mousedown'))
expect(listener).toHaveBeenCalledTimes(2)

// disable
active.value = false
await nextTick()
el1.dispatchEvent(new Event('mousedown'))
el2.dispatchEvent(new Event('mousedown'))
// events should no longer trigger
expect(listener).toHaveBeenCalledTimes(2)
})

it('should accept an array of DOM elements + multiple events', async () => {
const listener = vi.fn()
const el1 = document.createElement('button')
const el2 = document.createElement('button')
const arrayRef = computed(() => [el1, el2])

useEventListener(arrayRef, ['click', 'hover'], listener)
await nextTick()

el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
el1.dispatchEvent(new Event('hover'))
el2.dispatchEvent(new Event('hover'))
expect(listener).toHaveBeenCalledTimes(4)
})

it('should accept a getter returning multiple targets + multiple events', async () => {
const listener = vi.fn()
const el1 = document.createElement('div')
const el2 = document.createElement('div')
const active = ref(true)

useEventListener(() => active.value ? [el1, el2] : [], ['mousedown', 'click'], listener)
await nextTick()

el1.dispatchEvent(new Event('mousedown'))
el2.dispatchEvent(new Event('mousedown'))
el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
expect(listener).toHaveBeenCalledTimes(4)

// disable
active.value = false
await nextTick()
el1.dispatchEvent(new Event('mousedown'))
el2.dispatchEvent(new Event('mousedown'))
el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
// events should no longer trigger
expect(listener).toHaveBeenCalledTimes(4)
})

it('should react to target + event + function changes properly', async () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const el1 = document.createElement('div')
const el2 = document.createElement('div')
const els = ref([el1])
const events = ref(['click'])
const listeners = ref([listener1])

useEventListener(els, events, listeners)
el1.dispatchEvent(new Event('click'))
els.value = [el2]
await nextTick()
el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
events.value = ['mousedown']
await nextTick()
el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('mousedown'))
els.value = [el1, el2]
events.value = ['click', 'mousedown']
listeners.value = [listener1, listener2]
await nextTick()
el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(new Event('click'))
el1.dispatchEvent(new Event('mousedown'))
el2.dispatchEvent(new Event('mousedown'))

expect(listener1).toHaveBeenCalledTimes(7)
expect(listener2).toHaveBeenCalledTimes(4)
})
})

it('should auto re-register', async () => {
const target = ref()
const listener = vi.fn()
Expand Down
103 changes: 52 additions & 51 deletions packages/core/useEventListener/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Arrayable, Fn, MaybeRefOrGetter } from '@vueuse/shared'
import type { MaybeElementRef } from '../unrefElement'
import { isObject, noop, toArray, tryOnScopeDispose } from '@vueuse/shared'
import { toValue, watch } from 'vue'
import type { Arrayable, Fn, MaybeRef, MaybeRefOrGetter } from '@vueuse/shared'
import { isObject, toArray, tryOnScopeDispose, watchImmediate } from '@vueuse/shared'
// eslint-disable-next-line no-restricted-imports -- We specifically need to use unref here to distinguish between callbacks
import { computed, toValue, unref } from 'vue'
import { defaultWindow } from '../_configurable'
import { unrefElement } from '../unrefElement'

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

Expand All @@ -46,8 +47,8 @@ export function useEventListener<E extends keyof WindowEventMap>(
*/
export function useEventListener<E extends keyof WindowEventMap>(
target: Window,
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
event: MaybeRefOrGetter<Arrayable<E>>,
listener: MaybeRef<Arrayable<(this: Window, ev: WindowEventMap[E]) => any>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): Fn

Expand All @@ -64,8 +65,8 @@ export function useEventListener<E extends keyof WindowEventMap>(
*/
export function useEventListener<E extends keyof DocumentEventMap>(
target: DocumentOrShadowRoot,
event: Arrayable<E>,
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
event: MaybeRefOrGetter<Arrayable<E>>,
listener: MaybeRef<Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): Fn

Expand All @@ -81,10 +82,10 @@ export function useEventListener<E extends keyof DocumentEventMap>(
* @param options
*/
export function useEventListener<E extends keyof HTMLElementEventMap>(
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
event: Arrayable<E>,
listener: (this: HTMLElement, ev: HTMLElementEventMap[E]) => any,
options?: boolean | AddEventListenerOptions
target: MaybeRefOrGetter<Arrayable<HTMLElement> | null | undefined>,
event: MaybeRefOrGetter<Arrayable<E>>,
listener: MaybeRef<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): () => void

/**
Expand All @@ -99,9 +100,9 @@ export function useEventListener<E extends keyof HTMLElementEventMap>(
* @param options
*/
export function useEventListener<Names extends string, EventType = Event>(
target: MaybeRefOrGetter<InferEventTarget<Names> | null | undefined>,
event: Arrayable<Names>,
listener: Arrayable<GeneralEventListener<EventType>>,
target: MaybeRefOrGetter<Arrayable<InferEventTarget<Names>> | null | undefined>,
event: MaybeRefOrGetter<Arrayable<Names>>,
listener: MaybeRef<Arrayable<GeneralEventListener<EventType>>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): Fn

Expand All @@ -117,67 +118,67 @@ export function useEventListener<Names extends string, EventType = Event>(
* @param options
*/
export function useEventListener<EventType = Event>(
target: MaybeRefOrGetter<EventTarget | null | undefined>,
event: Arrayable<string>,
listener: Arrayable<GeneralEventListener<EventType>>,
target: MaybeRefOrGetter<Arrayable<EventTarget> | null | undefined>,
event: MaybeRefOrGetter<Arrayable<string>>,
listener: MaybeRef<Arrayable<GeneralEventListener<EventType>>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): Fn

export function useEventListener(...args: any[]) {
let target: MaybeRefOrGetter<EventTarget> | undefined
let events: Arrayable<string>
let listeners: Arrayable<Function>
let options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined

if (typeof args[0] === 'string' || Array.isArray(args[0])) {
[events, listeners, options] = args
target = defaultWindow
}
else {
[target, events, listeners, options] = args
}

if (!target)
return noop

events = toArray(events)
listeners = toArray(listeners)

export function useEventListener(...args: Parameters<typeof useEventListener>) {
const cleanups: Function[] = []
const cleanup = () => {
cleanups.forEach(fn => fn())
cleanups.length = 0
}

const register = (el: any, event: string, listener: any, options: any) => {
const register = (
el: EventTarget,
event: string,
listener: any,
options: boolean | AddEventListenerOptions | undefined,
) => {
el.addEventListener(event, listener, options)
return () => el.removeEventListener(event, listener, options)
}

const stopWatch = watch(
() => [unrefElement(target as unknown as MaybeElementRef), toValue(options)],
([el, options]) => {
const firstParamTargets = computed(() => {
const test = toArray(toValue(args[0])).filter(e => e != null)
return test.every(e => typeof e !== 'string') ? test : undefined
})

const stopWatch = watchImmediate(
() => [
firstParamTargets.value?.map(e => unrefElement(e as never)) ?? [defaultWindow].filter(e => e != null),
toArray(toValue(firstParamTargets.value ? args[1] : args[0])) as unknown as string[],
toArray(unref(firstParamTargets.value ? args[2] : args[1])) as Function[],
// @ts-expect-error - TypeScript gets the correct types, but somehow still complains
toValue(firstParamTargets.value ? args[3] : args[2]) as boolean | AddEventListenerOptions | undefined,
] as const,
([raw_targets, raw_events, raw_listeners, raw_options]) => {
cleanup()
if (!el)

if (!raw_targets?.length || !raw_events?.length || !raw_listeners?.length)
return

// create a clone of options, to avoid it being changed reactively on removal
const optionsClone = isObject(options) ? { ...options } : options
const optionsClone = isObject(raw_options) ? { ...raw_options } : raw_options
cleanups.push(
...(events as string[]).flatMap((event) => {
return (listeners as Function[]).map(listener => register(el, event, listener, optionsClone))
}),
...raw_targets.flatMap(el =>
raw_events.flatMap(event =>
raw_listeners.map(listener => register(el, event, listener, optionsClone)),
),
),
)
},
{ immediate: true, flush: 'post' },
{ flush: 'post' },
)

const stop = () => {
stopWatch()
cleanup()
}

tryOnScopeDispose(stop)
tryOnScopeDispose(cleanup)

return stop
}

0 comments on commit caf0851

Please sign in to comment.