diff --git a/packages/oruga/src/components/autocomplete/Autocomplete.vue b/packages/oruga/src/components/autocomplete/Autocomplete.vue index be4f7fc7b..875a5c3fa 100644 --- a/packages/oruga/src/components/autocomplete/Autocomplete.vue +++ b/packages/oruga/src/components/autocomplete/Autocomplete.vue @@ -535,9 +535,9 @@ function rightIconClick(event: Event): void { if (isClient && props.checkScroll) useEventListener( + computed(() => dropdownRef.value?.$content), "scroll", checkDropdownScroll, - computed(() => dropdownRef.value?.$content), ); /** Check if the scroll list inside the dropdown reached the top or it's end. */ diff --git a/packages/oruga/src/components/carousel/Carousel.vue b/packages/oruga/src/components/carousel/Carousel.vue index b70011ab1..ed0c1ff46 100644 --- a/packages/oruga/src/components/carousel/Carousel.vue +++ b/packages/oruga/src/components/carousel/Carousel.vue @@ -145,10 +145,10 @@ onBeforeUnmount(() => { // add dom event handler if (isClient) { - useEventListener("resize", onResized, window); - useEventListener("animationend", onRefresh); - useEventListener("transitionend", onRefresh); - useEventListener("transitionstart", onRefresh); + useEventListener(window, "resize", onResized); + useEventListener(document, "animationend", onRefresh); + useEventListener(document, "transitionend", onRefresh); + useEventListener(document, "transitionstart", onRefresh); } function onResized(): void { diff --git a/packages/oruga/src/components/dropdown/Dropdown.vue b/packages/oruga/src/components/dropdown/Dropdown.vue index 0ede70720..fc8076682 100644 --- a/packages/oruga/src/components/dropdown/Dropdown.vue +++ b/packages/oruga/src/components/dropdown/Dropdown.vue @@ -174,18 +174,21 @@ watch( if (cancelOptions.value.includes("outside")) { // set outside handler eventCleanups.push( - useClickOutside(contentRef, onClickedOutside, { - ignore: [triggerRef], - immediate: true, - passive: true, - }), + useClickOutside( + [contentRef, triggerRef], + onClickedOutside, + { + immediate: true, + passive: true, + }, + ), ); } if (cancelOptions.value.includes("escape")) { // set keyup handler eventCleanups.push( - useEventListener("keyup", onKeyPress, document, { + useEventListener(document, "keyup", onKeyPress, { immediate: true, }), ); @@ -287,7 +290,7 @@ function onClose(): void { // --- InfitiveScroll Feature --- if (isClient && props.checkScroll) - useEventListener("scroll", checkDropdownScroll, contentRef); + useEventListener(contentRef, "scroll", checkDropdownScroll); /** Check if the scroll list inside the dropdown reached the top or it's end. */ function checkDropdownScroll(): void { diff --git a/packages/oruga/src/components/loading/Loading.vue b/packages/oruga/src/components/loading/Loading.vue index 9c8949c48..4d18c64b6 100644 --- a/packages/oruga/src/components/loading/Loading.vue +++ b/packages/oruga/src/components/loading/Loading.vue @@ -72,7 +72,7 @@ watch(isActive, (value) => { if (isClient) { // register onKeyPress event when is active - useEventListener("keyup", onKeyPress, rootRef, { trigger: isActive }); + useEventListener(rootRef, "keyup", onKeyPress, { trigger: isActive }); } /** Keypress event that is bound to the document. */ diff --git a/packages/oruga/src/components/modal/Modal.vue b/packages/oruga/src/components/modal/Modal.vue index ccb37fee6..734aa4ad5 100644 --- a/packages/oruga/src/components/modal/Modal.vue +++ b/packages/oruga/src/components/modal/Modal.vue @@ -116,7 +116,7 @@ onMounted(() => { if (isClient) { // register onKeyPress event listener when is active - useEventListener("keyup", onKeyPress, rootRef, { trigger: isActive }); + useEventListener(rootRef, "keyup", onKeyPress, { trigger: isActive }); if (!props.overlay) // register outside click event listener when is active diff --git a/packages/oruga/src/components/sidebar/Sidebar.vue b/packages/oruga/src/components/sidebar/Sidebar.vue index 0da26e6da..e88387f15 100644 --- a/packages/oruga/src/components/sidebar/Sidebar.vue +++ b/packages/oruga/src/components/sidebar/Sidebar.vue @@ -129,7 +129,8 @@ onBeforeUnmount(() => { if (isClient) { // register onKeyPress event listener when is active - useEventListener("keyup", onKeyPress, rootRef, { trigger: isActive }); + useEventListener(rootRef, "keyup", onKeyPress, { trigger: isActive }); + if (!props.overlay) // register outside click event listener when is active useClickOutside(contentRef, clickedOutside, { trigger: isActive }); diff --git a/packages/oruga/src/components/tooltip/Tooltip.vue b/packages/oruga/src/components/tooltip/Tooltip.vue index 22c00cfd9..a17982a30 100644 --- a/packages/oruga/src/components/tooltip/Tooltip.vue +++ b/packages/oruga/src/components/tooltip/Tooltip.vue @@ -85,18 +85,21 @@ watch(isActive, (value) => { if (cancelOptions.value.indexOf("outside") >= 0) { // set outside handler eventCleanups.push( - useClickOutside(contentRef, onClickedOutside, { - ignore: [triggerRef], - immediate: true, - passive: true, - }), + useClickOutside( + [contentRef, triggerRef], + onClickedOutside, + { + immediate: true, + passive: true, + }, + ), ); } if (cancelOptions.value.indexOf("escape") >= 0) { // set keyup handler eventCleanups.push( - useEventListener("keyup", onKeyPress, document, { + useEventListener(document, "keyup", onKeyPress, { immediate: true, }), ); diff --git a/packages/oruga/src/components/utils/PickerWrapper.vue b/packages/oruga/src/components/utils/PickerWrapper.vue index 520756a3d..b49b52b07 100644 --- a/packages/oruga/src/components/utils/PickerWrapper.vue +++ b/packages/oruga/src/components/utils/PickerWrapper.vue @@ -203,7 +203,7 @@ const triggers = computed(() => isTrueish(props.pickerProps.openOnFocus) ? ["click"] : [], ); -if (isClient) useEventListener("keyup", onKeyPress); +if (isClient) useEventListener(document, "keyup", onKeyPress); /** Keypress event that is bound to the document. */ function onKeyPress(event: KeyboardEvent): void { diff --git a/packages/oruga/src/composables/tests/useClickOutside.test.ts b/packages/oruga/src/composables/tests/useClickOutside.test.ts new file mode 100644 index 000000000..bd5d469bb --- /dev/null +++ b/packages/oruga/src/composables/tests/useClickOutside.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { useClickOutside } from "../"; + +describe("useClickOutside test", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("should call handler when clicking outside the element", () => { + const handler = vi.fn(); + const element = document.createElement("div"); + document.body.appendChild(element); + + const stop = useClickOutside(element, handler, { immediate: true }); + vi.runAllTimers(); + + // Simulate click outside + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(handler).toHaveBeenCalled(); + + stop(); + document.body.removeChild(element); + }); + + test("should not call handler when clicking inside the element", () => { + const handler = vi.fn(); + const element = document.createElement("div"); + document.body.appendChild(element); + + const stop = useClickOutside(element, handler, { immediate: true }); + vi.runAllTimers(); + + // Simulate click inside + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(handler).not.toHaveBeenCalled(); + + stop(); + document.body.removeChild(element); + }); + + test("should respect ignore option", () => { + const handler = vi.fn(); + const element = document.createElement("div"); + const ignoreElement = document.createElement("div"); + document.body.appendChild(element); + document.body.appendChild(ignoreElement); + + const stop = useClickOutside([element, ignoreElement], handler, { + immediate: true, + }); + vi.runAllTimers(); + + // Simulate click on ignored element + ignoreElement.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(handler).not.toHaveBeenCalled(); + + stop(); + document.body.removeChild(element); + document.body.removeChild(ignoreElement); + }); +}); diff --git a/packages/oruga/src/composables/tests/useEventListener.test.ts b/packages/oruga/src/composables/tests/useEventListener.test.ts new file mode 100644 index 000000000..d9ef8ff36 --- /dev/null +++ b/packages/oruga/src/composables/tests/useEventListener.test.ts @@ -0,0 +1,163 @@ +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, + type MockInstance, +} from "vitest"; +import { effectScope, nextTick, ref, type Ref } from "vue"; + +import { useEventListener, type EventListenerOptions } from "../"; + +describe("useEventListener test", () => { + const options: EventListenerOptions = { immediate: true }; + let stop: () => void; + let target: HTMLDivElement; + let removeSpy: MockInstance; + let addSpy: MockInstance; + let listener: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + target = document.createElement("div"); + removeSpy = vi.spyOn(target, "removeEventListener"); + addSpy = vi.spyOn(target, "addEventListener"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("should be defined", () => { + expect(useEventListener).toBeDefined(); + }); + + describe("given event", () => { + const event = "click"; + + beforeEach(() => { + listener = vi.fn(); + }); + + test("should add listener", async () => { + stop = useEventListener(target, event, listener, { + immediate: true, + }); + vi.runAllTimers(); + expect(addSpy).toBeCalledTimes(1); + }); + + test("should trigger listener", () => { + stop = useEventListener(target, event, listener, { + immediate: true, + }); + vi.runAllTimers(); + expect(listener).not.toBeCalled(); + target.dispatchEvent(new MouseEvent(event)); + expect(listener).toBeCalledTimes(1); + }); + + test("should remove listener", () => { + stop = useEventListener(target, event, listener, { + immediate: true, + }); + vi.runAllTimers(); + expect(removeSpy).not.toBeCalled(); + + stop(); + + expect(removeSpy).toBeCalledTimes(1); + expect(removeSpy).toBeCalledWith(event, listener, options); + }); + }); + + describe("reactive target", () => { + let target: Ref; + + beforeEach(() => { + target = ref(document.createElement("div")); + }); + + test("should not listen when target is invalid", async () => { + useEventListener(target, "click", listener); + const el = target.value; + target.value = null; + await nextTick(); + el?.dispatchEvent(new MouseEvent("click")); + await nextTick(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + test(`should listen event`, async () => { + useEventListener(target, "click", listener, { immediate: true }); + vi.runAllTimers(); + target.value!.dispatchEvent(new MouseEvent("click")); + + await nextTick(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + test(`should manually stop listening event`, async () => { + const stop = useEventListener(target, "click", listener, { + immediate: true, + }); + + stop(); + + target.value!.dispatchEvent(new MouseEvent("click")); + + await nextTick(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + test(`should auto stop listening event`, async () => { + const scope = effectScope(); + await scope.run(async () => { + useEventListener(target, "click", listener, { + immediate: true, + }); + }); + + await scope.stop(); + + target.value!.dispatchEvent(new MouseEvent("click")); + + await nextTick(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + }); + + test("should auto register on trigger", async () => { + const trigger = ref(false); + + useEventListener(target, "click", listener, { trigger }); + + vi.runAllTimers(); + expect(addSpy).toHaveBeenCalledTimes(0); + + trigger.value = true; + await nextTick(); + vi.runAllTimers(); + + expect(addSpy).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenLastCalledWith("click", listener, { trigger }); + expect(removeSpy).toHaveBeenCalledTimes(0); + + trigger.value = false; + await nextTick(); + vi.runAllTimers(); + + await nextTick(); + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenLastCalledWith("click", listener, { + trigger, + }); + }); +}); diff --git a/packages/oruga/src/composables/useClickOutside.ts b/packages/oruga/src/composables/useClickOutside.ts index 20765d1ee..2c4c3e02d 100644 --- a/packages/oruga/src/composables/useClickOutside.ts +++ b/packages/oruga/src/composables/useClickOutside.ts @@ -6,35 +6,36 @@ import { } from "./useEventListener"; import { unrefElement } from "./unrefElement"; -export type ClickOutsideOptions = EventListenerOptions & { - ignore?: (MaybeRefOrGetter | string)[]; -}; - /** * Listen for clicks outside of an element. * Adaption of {@link https://vueuse.org/core/onClickOutside} * - * @param element DOM element to click outside + * @param elements DOM elements to click outside * @param handler Event handler function * @param options ClickOutsideOptions * @return stop function */ export function useClickOutside( - element: MaybeRefOrGetter, + elements: + | MaybeRefOrGetter + | string + | (MaybeRefOrGetter | string)[], handler: (evt: PointerEvent) => void, - options: ClickOutsideOptions = {}, + options?: EventListenerOptions, ): () => void { if (!window) return () => {}; // set default options const listenerOptions = Object.assign({ ignore: [] }, options); + // convert elements to ignore list + const ignores = Array.isArray(elements) ? elements : [elements]; /** * White-listed items that not emit event when clicked. * All children from ignore prop. */ const shouldIgnore = (event: PointerEvent): boolean => { - return listenerOptions.ignore.some((target) => { + return ignores.some((target) => { if (typeof target === "string") { return Array.from( window.document.querySelectorAll(target), @@ -53,15 +54,12 @@ export function useClickOutside( }); }; - const listener = (event: PointerEvent): void => { - const el = unrefElement(element); - if (!el || el === event.target || event.composedPath().includes(el)) - return; + function listener(event: PointerEvent): void { if (shouldIgnore(event)) return; handler(event); - }; + } - const stop = useEventListener("click", listener, window, listenerOptions); + const stop = useEventListener(window, "click", listener, listenerOptions); return stop; } diff --git a/packages/oruga/src/composables/useEventListener.ts b/packages/oruga/src/composables/useEventListener.ts index 3f8095f38..ecbd7ce58 100644 --- a/packages/oruga/src/composables/useEventListener.ts +++ b/packages/oruga/src/composables/useEventListener.ts @@ -22,16 +22,16 @@ export type EventListenerOptions = AddEventListenerOptions & { * Register DOM events using addEventListener on mounted, and removeEventListener automatically on unmounted. * Adaption of {@link https://vueuse.org/core/useEventListener} * + * @param element DOM element to add the listener to * @param event Event name * @param handler Event handler function - * @param element DOM element to add the listener to - default docuemnt * @param options EventListenerOptions * @return stop function */ export function useEventListener( + element: MaybeRefOrGetter, event: string, - handler: (evt?: any) => any, - element: MaybeRefOrGetter = document, + handler: (evt?: any) => void, options?: EventListenerOptions, ): () => void { let cleanup: () => void; @@ -45,12 +45,13 @@ export function useEventListener( // register listener with timeout to prevent animation collision setTimeout(() => { target.addEventListener(event, handler, optionsClone); - cleanup = (): void => + cleanup = (): void => { target.removeEventListener(event, handler, optionsClone); + }; }); }; - let stopWatch; + let stopWatch: () => void; if (typeof options?.trigger !== "undefined") { stopWatch = watch( @@ -58,14 +59,14 @@ export function useEventListener( (value) => { // toggle listener if (value) register(); - else stop(); + else if (typeof cleanup === "function") cleanup(); }, { flush: "post" }, ); } if (options?.immediate) register(); - else { + else if (getCurrentScope()) { // register listener on mount onMounted(() => { if ( diff --git a/packages/oruga/src/composables/useMatchMedia.ts b/packages/oruga/src/composables/useMatchMedia.ts index 3256426ba..8eb1ff6f4 100644 --- a/packages/oruga/src/composables/useMatchMedia.ts +++ b/packages/oruga/src/composables/useMatchMedia.ts @@ -9,7 +9,7 @@ import { isClient } from "@/utils/ssr"; */ export function useMatchMedia(mobileBreakpoint?: string) { const isMobile = ref(false); - const mediaQuery = ref(); + const mediaQuery = ref(); // getting a hold of the internal instance in setup() const vm = getCurrentInstance(); @@ -42,7 +42,7 @@ export function useMatchMedia(mobileBreakpoint?: string) { if (mediaQuery.value) { isMobile.value = mediaQuery.value.matches; - useEventListener("change", onMatchMedia, mediaQuery.value); + useEventListener(mediaQuery.value, "change", onMatchMedia); } else { isMobile.value = false; }