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 5 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'
158 changes: 158 additions & 0 deletions src/useEventListener/useEventListener.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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'
import * as utils from '@/utilities/vue'

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)
vi.spyOn(utils, 'toValue').mockReturnValue(initialValue)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? Won't toValue already return the initial value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, this is so obvious in retrospect. Check out the updated tests, I think you'll find them significantly more concise


useEventListener(target, 'click', callback)

if (target.value) {
const addEventListenerMock = vi.spyOn(target.value, 'addEventListener')
expect(addEventListenerMock).not.toBeCalled()
}
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved

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

it('adds event listener', () => {
const target = ref<HTMLParagraphElement>()
const element = document.createElement('p')
vi.spyOn(utils, 'toValue').mockReturnValue(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 target = ref<HTMLParagraphElement>()
const element = document.createElement('p')
vi.spyOn(utils, 'toValue').mockReturnValue(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 target = ref<HTMLParagraphElement>()
const element = document.createElement('p')
vi.spyOn(utils, 'toValue').mockReturnValue(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 target = ref<HTMLParagraphElement>()
const element = document.createElement('p')
vi.spyOn(utils, 'toValue').mockReturnValue(element)
const addEventListenerMock = vi.spyOn(element, 'removeEventListener')

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

expect(addEventListenerMock).toHaveBeenCalledOnce()
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
})

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

useEventListener(target, 'click', callback)

fireEvent.click(element)

expect(callback).toHaveBeenCalledOnce()
})

it('on scope dispose removes listener', () => {
const target = ref<HTMLElement>()

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

const element = document.createElement('p')
vi.spyOn(utils, 'toValue').mockReturnValue(element)
const addEventListenerMock = vi.spyOn(element, 'removeEventListener')

unmount()

expect(addEventListenerMock).toHaveBeenCalled()
})

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

const currentElement = ref(originalElement)
vi.spyOn(utils, 'toValue').mockImplementation(() => currentElement.value)

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

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

currentElement.value = updatedElement

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

expect(originalAddEventListenerMock).toHaveBeenCalledOnce()
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
expect(originalRemoveEventListenerMock).toHaveBeenCalledOnce()
expect(updatedAddEventListenerMock).toHaveBeenCalledOnce()
})

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

const currentElement = ref(originalElement)
vi.spyOn(utils, 'toValue').mockImplementation(() => currentElement.value)

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

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

remove()

currentElement.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)
}
Loading