From 8e80b353b04fdaa00a918b2876abb3bb7bfc0018 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 4 Aug 2020 20:19:48 +0200 Subject: [PATCH] feat: initial version for Vue 3 Co-authored-by: Damian Dulisz --- __tests__/index.spec.ts | 234 ++++++++++++++++++++++++++++++++++++++-- jest.config.js | 1 + src/__mocks__/isIE.ts | 6 ++ src/global.d.ts | 2 + src/index.ts | 146 +++++++++++++++++++++---- src/isIE.ts | 6 ++ 6 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 src/__mocks__/isIE.ts create mode 100644 src/global.d.ts create mode 100644 src/isIE.ts diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index 9ef4be7..aa43a70 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -1,19 +1,231 @@ -import { mylib, ComponentImpl } from '../src' +import { GlobalEventsImpl as GlobalEvents } from '../src' import { mount } from '@vue/test-utils' +// @ts-ignore +import ie from '../src/isIE' -describe('mylib', () => { - it('works', () => { - expect(mylib()).toBe(true) +jest.mock('../src/isIE.ts') + +describe('GlobalEvents', () => { + test('transfer events', () => { + const onKeydown = jest.fn() + const onCallcontext = jest.fn() + mount(GlobalEvents, { + attrs: { + onKeydown, + onCallcontext, + }, + }) + + expect(onKeydown).not.toHaveBeenCalled() + expect(onCallcontext).not.toHaveBeenCalled() + + document.dispatchEvent(new Event('keydown')) + + expect(onKeydown).toHaveBeenCalledTimes(1) + expect(onCallcontext).not.toHaveBeenCalled() + }) + + test('filter out events', () => { + const onKeydown = jest.fn() + let called = false + // easy to test filter that calls only the filst event + const filter = () => { + const shouldCall = !called + called = true + return shouldCall + } + mount(GlobalEvents, { + attrs: { onKeydown }, + // @ts-ignore + props: { filter }, + }) + expect(onKeydown).not.toHaveBeenCalled() + + document.dispatchEvent(new Event('keydown')) + expect(onKeydown).toHaveBeenCalledTimes(1) + + document.dispatchEvent(new Event('keydown')) + document.dispatchEvent(new Event('keydown')) + document.dispatchEvent(new Event('keydown')) + expect(onKeydown).toHaveBeenCalledTimes(1) + }) + + test('filter gets passed handler, and keyName', () => { + const onKeydown = jest.fn() + const filter = jest.fn() + mount(GlobalEvents, { + attrs: { onKeydown }, + // @ts-ignore: should work + props: { filter }, + }) + + const event = new Event('keydown') + document.dispatchEvent(event) + expect(onKeydown).not.toHaveBeenCalled() + + // Vue will wrap the onKeydown listener, that's why we are checking for fns + expect(filter).toHaveBeenCalledWith(event, expect.any(Function), 'keydown') + }) + + test('cleans up events', () => { + const onKeydown = jest.fn() + const onCallcontext = jest.fn() + const wrapper = mount(GlobalEvents, { + attrs: { + onKeydown, + onCallcontext, + }, + }) + + const spy = (document.removeEventListener = jest.fn()) + + wrapper.unmount() + + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), undefined) + expect(spy).toHaveBeenCalledWith( + 'callcontext', + expect.any(Function), + undefined + ) + + spy.mockRestore() + }) + + test('cleans up events with modifiers', () => { + const keydown = jest.fn() + const wrapper = mount(GlobalEvents, { + attrs: { + onKeydownCapture: keydown, + }, + }) + + const spy = (document.removeEventListener = jest.fn()) + + wrapper.unmount() + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), { + capture: true, + }) + + spy.mockRestore() }) -}) -describe('Component', () => { - it('works', async () => { - const wrapper = mount(ComponentImpl, { - props: { data: { title: 'hi', summary: 'summary' } }, + test('supports passive modifier', () => { + const onKeydown = jest.fn() + const spy = (document.addEventListener = jest.fn()) + mount(GlobalEvents, { + attrs: { + onKeydownPassive: onKeydown, + }, }) - expect(wrapper.html()).toMatchInlineSnapshot( - `"

Custom: false. hi - summary.

"` + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), { + passive: true, + }) + + spy.mockRestore() + }) + + test('strips off modifiers from events', () => { + const keydown = jest.fn() + const spy = (document.addEventListener = jest.fn()) + mount(GlobalEvents, { + attrs: { + onKeydownOnce: keydown, + }, + }) + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), { + once: true, + }) + spy.mockRestore() + }) + + test('supports capture modifier', () => { + const keydown = jest.fn() + const spy = (document.addEventListener = jest.fn()) + mount(GlobalEvents, { + attrs: { + onKeydownCapture: keydown, + }, + }) + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), { + capture: true, + }) + spy.mockRestore() + }) + + test('supports once modifier', () => { + const keydown = jest.fn() + const spy = (document.addEventListener = jest.fn()) + mount(GlobalEvents, { + attrs: { + onKeydownOnce: keydown, + }, + }) + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), { + once: true, + }) + spy.mockRestore() + }) + + test('supports multiple modifier', () => { + const keydown = jest.fn() + const spy = (document.addEventListener = jest.fn()) + mount(GlobalEvents, { + attrs: { + onKeydownOnceCapture: keydown, + }, + }) + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), { + capture: true, + once: true, + }) + spy.mockRestore() + }) + + test('passes a boolean instead of object if IE', () => { + const keydown = jest.fn() + const spy = (document.addEventListener = jest.fn()) + // @ts-ignore + ie.value = true + mount(GlobalEvents, { + attrs: { + onKeydownOnceCapture: keydown, + onKeydownOnce: keydown, + }, + }) + // @ts-ignore + ie.value = false + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), true) + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function), false) + spy.mockRestore() + }) + + test('support different targets', () => { + const keydown = jest.fn() + const spy = jest.spyOn(global.window, 'addEventListener') + mount(GlobalEvents, { + // @ts-ignore + props: { + target: 'window', + }, + attrs: { + onKeydownOnceCapture: keydown, + }, + }) + + expect(global.window.addEventListener).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + { capture: true, once: true } ) + spy.mockRestore() }) }) diff --git a/jest.config.js b/jest.config.js index 7ff7112..b264465 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,6 @@ module.exports = { preset: 'ts-jest', + env: 'jsdom', collectCoverage: true, collectCoverageFrom: ['/src/**/*.ts'], testMatch: ['/__tests__/**/*.spec.ts'], diff --git a/src/__mocks__/isIE.ts b/src/__mocks__/isIE.ts new file mode 100644 index 0000000..38b61b5 --- /dev/null +++ b/src/__mocks__/isIE.ts @@ -0,0 +1,6 @@ +const mock = { + value: false, + isIE: () => mock.value, +} + +module.exports = mock diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..ca93dc0 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,2 @@ +// Global compile-time constants +declare var __DEV__: boolean diff --git a/src/index.ts b/src/index.ts index aa7ecd0..6448a71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,134 @@ -import { defineComponent, PropType, VNodeProps, h } from 'vue' +import { + defineComponent, + PropType, + VNodeProps, + onMounted, + onBeforeUnmount, +} from 'vue' +import { isIE } from './isIE' -/** - * Returns true. - */ -export function mylib() { - return true +const EVENT_NAME_RE = /^on(\w+?)((?:Once|Capture|Passive)*)$/ +const MODIFIERS_SEPARATOR_RE = /[OCP]/g + +export interface GlobalEventsProps { + target?: Exclude + filter?: EventFilter } -export interface ComponentProps { - custom?: boolean - data: { title: string; summary: string } +export type EventFilter = ( + event: Event, + listener: EventListener, + name: string +) => any + +type Options = AddEventListenerOptions & EventListenerOptions + +function extractEventOptions( + modifiersRaw: string | undefined +): Options | undefined | boolean { + if (!modifiersRaw) return + + const modifiers = modifiersRaw + .replace(MODIFIERS_SEPARATOR_RE, ',$&') + .toLowerCase() + // remove the initial comma + .slice(1) + .split(',') as Array<'capture' | 'passive' | 'once'> + + // IE only supports capture option and it has to be a boolean + // https://github.com/shentao/vue-global-events/issues/14 + if (isIE()) { + return modifiers.includes('capture') + } + + return modifiers.reduce((options, modifier) => { + options[modifier] = true + return options + }, {} as Options) } -export const ComponentImpl = defineComponent({ +export const GlobalEventsImpl = defineComponent({ + name: 'GlobalEvents', + props: { - custom: Boolean, - data: { - required: true, - type: Object as PropType, + target: { + type: String, + default: 'document', + }, + filter: { + type: Function as PropType, + default: () => () => true, }, }, - setup(props) { - return () => - h( - 'p', - `Custom: ${props.custom}. ${props.data.title} - ${props.data.summary}.` - ) + setup(props, { attrs }) { + let activeListeners: Record< + string, + [EventListener[], string, Options | undefined] + > = Object.create(null) + + onMounted(() => { + Object.keys(attrs) + .filter((name) => name.startsWith('on')) + .forEach((eventNameWithModifiers) => { + const listener = attrs[eventNameWithModifiers] as + | EventListener + | EventListener[] + const listeners = Array.isArray(listener) ? listener : [listener] + const match = eventNameWithModifiers.match(EVENT_NAME_RE) + + if (!match) { + if (__DEV__) { + console.warn( + `[vue-global-events] Unable to parse "${eventNameWithModifiers}". If this should work, you should probably open a new issue on https://github.com/shentao/vue-global-events.` + ) + } + return + } + + let [, eventName, modifiersRaw] = match + eventName = eventName.toLowerCase() + + const handlers: EventListener[] = listeners.map( + (listener) => (event) => { + props.filter(event, listener, eventName) && listener(event) + } + ) + + const options = extractEventOptions(modifiersRaw) + + handlers.forEach((handler) => { + ;(window[props.target as keyof Window] as Element).addEventListener( + eventName, + handler, + options + ) + }) + + activeListeners[eventNameWithModifiers] = [ + handlers, + eventName, + options, + ] + }) + }) + + onBeforeUnmount(() => { + for (const eventNameWithModifiers in activeListeners) { + const [handlers, eventName, options] = activeListeners[ + eventNameWithModifiers + ] + handlers.forEach((handler) => { + ;(window[ + props.target as keyof Window + ] as Element).removeEventListener(eventName, handler, options) + }) + } + + activeListeners = {} + }) + + return () => null }, }) @@ -35,8 +137,8 @@ export const ComponentImpl = defineComponent({ /** * Component of vue-lib. */ -export const Component = (ComponentImpl as any) as { +export const Component = (GlobalEventsImpl as any) as { new (): { - $props: VNodeProps & ComponentProps + $props: VNodeProps & GlobalEventsProps } } diff --git a/src/isIE.ts b/src/isIE.ts new file mode 100644 index 0000000..8d7008d --- /dev/null +++ b/src/isIE.ts @@ -0,0 +1,6 @@ +let _isIE: boolean +export function isIE() { + return _isIE == null + ? (_isIE = /msie|trident/.test(window.navigator.userAgent.toLowerCase())) + : _isIE +}