-
Notifications
You must be signed in to change notification settings - Fork 2
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
pleek91
merged 7 commits into
PrefectHQ:main
from
stackoverfloweth:useEventListener-composition
Aug 19, 2023
Merged
Changes from 5 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
afc0b61
add shim support for vue 3.3
stackoverfloweth ffaa0db
added new useEventListener composition
stackoverfloweth 4ce3b23
using useEventListener to drive functionality of useGlobalEventListener
stackoverfloweth 78b144f
code review suggestions
stackoverfloweth 829d540
added unit tests for useEventListener
stackoverfloweth 000ce70
code review suggestions
stackoverfloweth e543f20
code review suggestions
stackoverfloweth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './useEventListener' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
||
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
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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