Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use event listener composition #293

Merged
merged 7 commits into from
Aug 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
# vue-compositions

A collection of reusable vue compositions

## Installation
```

```bash
npm i --save @prefecthq/vue-compositions
```

## Compositions

- [useBoolean](https://github.com/prefecthq/vue-compositions/tree/main/src/useBoolean)
- [useChildrenAreWrapped](https://github.com/prefecthq/vue-compositions/tree/main/src/useChildrenAreWrapped)
- [useComputedStyle](https://github.com/prefecthq/vue-compositions/tree/main/src/useComputedStyle)
- [useDebouncedRef](https://github.com/prefecthq/vue-compositions/tree/main/src/useDebouncedRef)
- [useElementRect](https://github.com/prefecthq/vue-compositions/tree/main/src/useElementRect)
- [useElementWidth](https://github.com/prefecthq/vue-compositions/tree/main/src/useElementWidth)
- [useEventListener](https://github.com/prefecthq/vue-compositions/tree/main/src/useEventListener)
- [useGlobalEventListener](https://github.com/prefecthq/vue-compositions/tree/main/src/useGlobalEventListener)
- [useIntersectionObserver](https://github.com/prefecthq/vue-compositions/tree/main/src/useIntersectionObserver)
- [useIsSame](https://github.com/prefecthq/vue-compositions/tree/main/src/useIsSame)
Expand Down
1 change: 1 addition & 0 deletions src/types/maybe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { Ref, UnwrapRef } from 'vue'
export type MaybePromise<T = unknown> = T | Promise<T>
export type MaybeRef<T = unknown> = T | Ref<T>
export type MaybeUnwrapRef<T = unknown> = T | UnwrapRef<T>
export type MaybeRefOrGetter<T = unknown> = MaybeRef<T> | (() => T)
export type MaybeArray<T = unknown> = T | T[]
35 changes: 35 additions & 0 deletions src/useEventListener/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# useEventListener

The `useEventListener` composition can be used to automatically handle setup and teardown of event listeners on the document or HTMElement scope. The options argument extends browser AddEventListenerOptions with `immediate` boolean, which defaults to `true` but when passed in as `false`, will prevent the composition from adding the listener automatically.

The composition will return two methods `add` and `remove`. Calling add will trigger `addEventListener` on the target. Calling `remove` will trigger `removeEventListener` on the target.

The composition uses a watcher to remove and re-add the eventListener automatically when the `target` changes. Note this will NOT execute if `options.immediate` is `false`, or if `remove` is called.

## Example

```typescript
import { useEventListener } from '@prefecthq/vue-compositions'

function handleEvent(event: MouseEvent) {
// Respond to event
}
const element = ref<HTMLDivElement | undefined>()
useEventListener(element, 'keyup', handleEvent)
```

## Arguments

| Name | Type | Default |
|-----------|-----------------------------------------------------------|-----------|
| target | `MaybeRefOrGetter<Document \| HTMLElement \| undefined \| null>` | None |
| type | `K (K extends keyof DocumentEventMap)` | None |
| callback | `(this: Document \| HTMLElement, event: DocumentEventMap[K] \| HTMLElementEventMap[K]) => unknown` | None |
| options | `AddEventListenerOptions & { immediate: boolean }` | { immediate: true } |

## Returns

| Name | Type | Description |
|--------|-------------|---------------------------------------------------|
| add | () => void | Manually attach the event listener (has no effect if the event listener already exists) |
| remove | () => void | Manually detach the event listener, prevent watch from automatically reattaching on target change. |
1 change: 1 addition & 0 deletions src/useEventListener/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useEventListener'
136 changes: 136 additions & 0 deletions src/useEventListener/useEventListener.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { fireEvent, render } from '@testing-library/vue'
import { vi, describe, it, test, expect, afterEach } from 'vitest'
import { ref } from 'vue'
import { useEventListener } from '@/useEventListener/useEventListener'
import { timeout } from '@/utilities/tests'

describe('useEventListener', () => {

afterEach(() => {
vi.restoreAllMocks()
})

test.each<null | undefined>([
undefined,
null,
])('given falsy target never adds event listener', (initialValue) => {
const callback = vi.fn()
const target = ref<HTMLElement | undefined | null>(initialValue)

useEventListener(target, 'click', callback)

expect(callback).not.toBeCalled()
})

it('adds event listener', () => {
const element = document.createElement('p')
const target = ref<HTMLParagraphElement>(element)
const addEventListenerMock = vi.spyOn(element, 'addEventListener')

useEventListener(target, 'click', vi.fn())

expect(addEventListenerMock).toHaveBeenCalledOnce()
})

it('given immediate false wont automatically add event listener', () => {
const element = document.createElement('p')
const target = ref<HTMLParagraphElement>(element)
const addEventListenerMock = vi.spyOn(element, 'addEventListener')

useEventListener(target, 'click', vi.fn(), { immediate: false })

expect(addEventListenerMock).not.toHaveBeenCalled()
})

it('add is called always adds listener', () => {
const element = document.createElement('p')
const target = ref<HTMLParagraphElement>(element)
const addEventListenerMock = vi.spyOn(element, 'addEventListener')

const { add } = useEventListener(target, 'click', vi.fn(), { immediate: false })
add()

expect(addEventListenerMock).toHaveBeenCalledOnce()
})

it('remove is called always removes listener', () => {
const element = document.createElement('p')
const target = ref<HTMLParagraphElement>(element)
const removeEventListenerMock = vi.spyOn(element, 'removeEventListener')

const { remove } = useEventListener(target, 'click', vi.fn(), { immediate: false })
remove()

expect(removeEventListenerMock).toHaveBeenCalledOnce()
})

it('triggers callback on event', () => {
const callback = vi.fn()
const element = document.createElement('p')
const target = ref<HTMLParagraphElement>(element)

useEventListener(target, 'click', callback)

fireEvent.click(element)

expect(callback).toHaveBeenCalledOnce()
})

it('on scope dispose removes listener', () => {
const element = document.createElement('p')
const target = ref<HTMLElement>(element)
const addEventListenerMock = vi.spyOn(element, 'removeEventListener')

const { unmount } = render({
setup: () => {
useEventListener(target, 'click', vi.fn(), { immediate: false })
},
})

unmount()

expect(addEventListenerMock).toHaveBeenCalled()
})

it('changing target automatically reattaches event listener', async () => {
const originalElement = document.createElement('p')
const target = ref<HTMLParagraphElement>(originalElement)
const updatedElement = document.createElement('p')

const originalRemoveEventListenerMock = vi.spyOn(originalElement, 'removeEventListener')
const updatedAddEventListenerMock = vi.spyOn(updatedElement, 'addEventListener')

useEventListener(target, 'click', vi.fn())

target.value = updatedElement

// because reattaching would happen in watch
await timeout()

expect(originalRemoveEventListenerMock).toHaveBeenCalledOnce()
expect(updatedAddEventListenerMock).toHaveBeenCalledOnce()
})

it('changing target wont reattach if remove was called', async () => {
const originalElement = document.createElement('p')
const target = ref<HTMLParagraphElement>(originalElement)
const updatedElement = document.createElement('div')

const originalAddEventListenerMock = vi.spyOn(originalElement, 'addEventListener')
const updatedAddEventListenerMock = vi.spyOn(updatedElement, 'addEventListener')

const { remove } = useEventListener(target, 'click', vi.fn())

remove()

target.value = updatedElement

// because reattaching would happen in watch
await timeout()

expect(originalAddEventListenerMock).toHaveBeenCalledOnce()
expect(updatedAddEventListenerMock).not.toHaveBeenCalled()
})

})

stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
52 changes: 52 additions & 0 deletions src/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ref, watch } from 'vue'
import { MaybeRefOrGetter } from '@/types/maybe'
import { tryOnScopeDispose } from '@/utilities/tryOnScopeDispose'
import { toValue } from '@/utilities/vue'

export type UseEventListener = {
add: () => void,
remove: () => void,
}

export type UseEventListenerOptions = AddEventListenerOptions & {
immediate?: boolean,
}

const defaultOptions: UseEventListenerOptions = {
immediate: true,
}

export function useEventListener<K extends keyof DocumentEventMap>(target: MaybeRefOrGetter<Document | undefined | null>, key: K, callback: (this: Document, event: DocumentEventMap[K]) => unknown, options?: UseEventListenerOptions): UseEventListener
export function useEventListener<K extends keyof HTMLElementEventMap>(target: MaybeRefOrGetter<HTMLElement | undefined | null>, key: K, callback: (this: HTMLElement, event: HTMLElementEventMap[K]) => unknown, options?: UseEventListenerOptions): UseEventListener
// eslint-disable-next-line max-params
export function useEventListener<K extends string>(target: MaybeRefOrGetter<Node | undefined | null>, key: K, callback: (this: Node, event: Event) => unknown, options: UseEventListenerOptions = {}): UseEventListener {
const { immediate, ...listenerOptions } = { ...defaultOptions, ...options }
const manualMode = ref(!immediate)

function addEventListener(): void {
toValue(target)?.addEventListener(key, callback, listenerOptions)
}

function removeEventListener(): void {
toValue(target)?.removeEventListener(key, callback, listenerOptions)
}

tryOnScopeDispose(removeEventListener)

watch(() => toValue(target), () => {
if (!manualMode.value) {
removeEventListener()
addEventListener()
}
}, { immediate: true })

return {
add: () => {
addEventListener()
},
remove: () => {
manualMode.value = true
removeEventListener()
},
}
}
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 6 additions & 2 deletions src/useGlobalEventListener/README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
# useGlobalEventListener

The `useGlobalEventListener` composition can be used to automatically handle setup and teardown of event listeners on the document scope. It takes the same arguments as the global `addEventListener` method

## Example

```typescript
import { useGlobalEventListener } from '@prefecthq/vue-compositions'

function handleEvent(event: Event) {
function handleEvent(event: MouseEvent) {
// Respond to event
}
useGlobalEventListener('keyup', handleEvent)
```

## Arguments

| Name | Type | Default |
|-----------|-----------------------------------------------------------|-----------|
| type | `K (K extends keyof DocumentEventMap)` | None |
| callback | `(this: Document, event: DocumentEventMap[K]) => unknown` | None |
| options | `AddEventListenerOptions` | None |

## Returns

| Name | Type | Description |
|--------|-------------|---------------------------------------------------|
| add | () => void | Manually attach the event listener (has no effect if the event listener already exists) |
| remove | () => void | Manually detach the event listener |
| remove | () => void | Manually detach the event listener |
18 changes: 3 additions & 15 deletions src/useGlobalEventListener/useGlobalEventListener.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { tryOnScopeDispose } from '@/utilities/tryOnScopeDispose'
import { useEventListener } from '@/useEventListener'

type UseGlobalEventListener = {
add: () => void,
Expand Down Expand Up @@ -31,20 +31,8 @@ type UseGlobalEventListener = {
export function useGlobalEventListener<K extends keyof DocumentEventMap>(
type: K,
callback: (this: Document, event: DocumentEventMap[K]) => unknown,
options?: boolean | AddEventListenerOptions,
options?: AddEventListenerOptions,
): UseGlobalEventListener {

const add = (): void => {
document.addEventListener(type, callback, options)
}

const remove = (): void => {
document.removeEventListener(type, callback, options)
}

add()
tryOnScopeDispose(remove)

return { add, remove }
return useEventListener(document, type, callback, options)
}

8 changes: 8 additions & 0 deletions src/utilities/vue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { unref } from 'vue'
import { MaybeRefOrGetter } from '@/types/maybe'
import { isFunction } from '@/utilities/functions'

// temp shim for Vue 3.3^ function
export function toValue<T>(source: MaybeRefOrGetter<T>): T {
return isFunction(source) ? source() : unref(source)
}