diff --git a/README.md b/README.md index 8d38780..1ab8d90 100755 --- a/README.md +++ b/README.md @@ -18,12 +18,16 @@ > 🛠️ Vue kit of useful [Vue Composition API](https://vue-composition-api-rfc.netlify.com) functions. -Please note that Vue 3.0 has not been released yet, therefore the installation and setup of [@vue/composition-api](https://github.com/vuejs/composition-api) is required for this library to work. - ## Install ```shell script -npm install @vue/composition-api vue-use-kit +npm install vue-use-kit +``` + +Since Vue 3.0 has not yet been released, you must also install [@vue/composition-api](https://github.com/vuejs/composition-api) library, which will enable the composition API in Vue 2.0. + +```shell script +npm install @vue/composition-api ``` ## Setup @@ -78,6 +82,8 @@ Vue.use(VueCompositionAPI); [![Demo](https://img.shields.io/badge/advanced_demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-usemouse--advanced-demo) - [`useMouseElement`](./src/components/useMouseElement/stories/useMouseElement.md) — tracks the mouse position relative to given element. [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-usemouseelement--demo) + - [`useSearchParams`](./src/components/useSearchParams/stories/useSearchParams.md) — tracks browser's location search params. + [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-usesearchparams--demo) - Animations - [`useInterval`](./src/components/useInterval/stories/useInterval.md) — updates `counter` value repeatedly on a fixed time delay. [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/animations-useinterval--demo) diff --git a/src/components/useClickAway/useClickAway.spec.ts b/src/components/useClickAway/useClickAway.spec.ts index 9b7e58f..e949b38 100755 --- a/src/components/useClickAway/useClickAway.spec.ts +++ b/src/components/useClickAway/useClickAway.spec.ts @@ -28,7 +28,6 @@ const testComponent = () => ({ describe('useClickAway', () => { it('should call document.addEventListener', async () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener') - expect(addEventListenerSpy).not.toHaveBeenCalled() const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') const wrapper = mount(testComponent()) await wrapper.vm.$nextTick() diff --git a/src/components/useIdle/stories/UseIdleDemo.vue b/src/components/useIdle/stories/UseIdleDemo.vue index 29e4f3e..0758761 100755 --- a/src/components/useIdle/stories/UseIdleDemo.vue +++ b/src/components/useIdle/stories/UseIdleDemo.vue @@ -15,12 +15,12 @@ - @@ -37,19 +37,8 @@ import { useIdle } from '@src/vue-use-kit' export default Vue.extend({ name: 'UseIdleDemo', setup() { - const { isIdle, start, stop } = useIdle(2500) - - const isTracking = ref(true) - const startTracking = () => { - isTracking.value = true - start() - } - const stopTracking = () => { - isTracking.value = false - stop() - } - - return { isIdle, isTracking, startTracking, stopTracking } + const { isIdle, isTracking, start, stop } = useIdle(2500) + return { isIdle, isTracking, start, stop } } }) diff --git a/src/components/useIdle/stories/useIdle.md b/src/components/useIdle/stories/useIdle.md index 150c7c0..9f4e89c 100755 --- a/src/components/useIdle/stories/useIdle.md +++ b/src/components/useIdle/stories/useIdle.md @@ -11,6 +11,7 @@ function useIdle( runOnMount?: boolean ): { isIdle: Ref; + isTracking: Ref; start: () => void; stop: () => void; } @@ -25,6 +26,7 @@ function useIdle( ### Returns - `isIdle: Ref` it is `true` when the user is idle, `false` otherwise +- `isTracking: Ref` whether the function is tracking the user idle state or not - `start: Function` the function used for start tracking the user's idle state - `stop: Function` the function used for stop tracking the user's idle state @@ -34,8 +36,8 @@ function useIdle( @@ -46,19 +48,8 @@ function useIdle( export default Vue.extend({ name: 'UseIdleDemo', setup() { - const { isIdle, start, stop } = useIdle(2500) - - const isTracking = ref(true) - const startTracking = () => { - isTracking.value = true - start() - } - const stopTracking = () => { - isTracking.value = false - stop() - } - - return { isIdle, isTracking, startTracking, stopTracking } + const { isIdle, isTracking, start, stop } = useIdle(2500) + return { isIdle, isTracking, start, stop } } }) diff --git a/src/components/useIdle/useIdle.spec.ts b/src/components/useIdle/useIdle.spec.ts index eef4040..68ac1d4 100755 --- a/src/components/useIdle/useIdle.spec.ts +++ b/src/components/useIdle/useIdle.spec.ts @@ -25,7 +25,6 @@ describe('useIdle', () => { it('should call document.addEventListener', async () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener') - expect(addEventListenerSpy).not.toHaveBeenCalled() const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') const wrapper = mount(testComponent()) await wrapper.vm.$nextTick() diff --git a/src/components/useIdle/useIdle.ts b/src/components/useIdle/useIdle.ts index f78e904..5b06a7b 100755 --- a/src/components/useIdle/useIdle.ts +++ b/src/components/useIdle/useIdle.ts @@ -18,6 +18,7 @@ export function useIdle( ) { let timeout: any = null const isIdle = ref(false) + const isTracking = ref(false) const handleChange = throttle(50, () => { isIdle.value = false @@ -34,14 +35,17 @@ export function useIdle( } const start = () => { + if (isTracking.value) return events.forEach(evtName => document.addEventListener(evtName, handleChange)) document.addEventListener('visibilitychange', handleVisibility) // Initialize it since the events above may not run immediately handleChange() + isTracking.value = true } const stop = () => { + if (!isTracking.value) return events.forEach(evtName => document.removeEventListener(evtName, handleChange) ) @@ -53,10 +57,11 @@ export function useIdle( // Restore initial status timeout = null isIdle.value = false + isTracking.value = false } onMounted(() => runOnMount && start()) onUnmounted(stop) - return { isIdle, start, stop } + return { isIdle, isTracking, start, stop } } diff --git a/src/components/useLocation/useLocation.spec.ts b/src/components/useLocation/useLocation.spec.ts index b5c7724..7bedb18 100755 --- a/src/components/useLocation/useLocation.spec.ts +++ b/src/components/useLocation/useLocation.spec.ts @@ -39,7 +39,6 @@ describe('useLocation', () => { it('should call popstate, pushstate and replacestate onMounted', async () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener') - expect(addEventListenerSpy).not.toHaveBeenCalled() const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') const wrapper = mount(testComponent()) await wrapper.vm.$nextTick() diff --git a/src/components/useLocation/useLocation.ts b/src/components/useLocation/useLocation.ts index 2e5fd2a..c0d6a71 100755 --- a/src/components/useLocation/useLocation.ts +++ b/src/components/useLocation/useLocation.ts @@ -1,4 +1,5 @@ import { ref, onMounted, onUnmounted, Ref } from '@src/api' +import { patchHistoryMethodsOnce } from '@src/utils' export interface UseLocationState { trigger: string @@ -15,28 +16,6 @@ export interface UseLocationState { search: string } -// The history methods 'pushState' and 'replaceState' by default do not fire an event -// unless it is coming from user interaction with the browser navigation bar, -// so we are adding a patch to make them detectable -let isPatched = false -const patchHistoryMethodsOnce = () => { - if (isPatched) return - const methods = ['pushState', 'replaceState'] - methods.forEach(method => { - const original = (history as any)[method] - ;(history as any)[method] = function(state: any) { - // eslint-disable-next-line prefer-rest-params - const result = original.apply(this, arguments) - const event = new Event(method.toLowerCase()) - ;(event as any).state = state - window.dispatchEvent(event) - return result - } - }) - - isPatched = true -} - export function useLocation(runOnMount = true) { const buildState = (trigger: string) => { const { state, length } = history @@ -76,23 +55,21 @@ export function useLocation(runOnMount = true) { const replaceState = () => (locationState.value = buildState('replacestate')) const start = () => { - patchHistoryMethodsOnce() - if (isTracking.value) return - isTracking.value = true - + patchHistoryMethodsOnce() locationState.value = buildState('start') window.addEventListener('popstate', popState) window.addEventListener('pushstate', pushState) window.addEventListener('replacestate', replaceState) + isTracking.value = true } const stop = () => { if (!isTracking.value) return - isTracking.value = false window.removeEventListener('popstate', popState) window.removeEventListener('pushstate', pushState) window.removeEventListener('replacestate', replaceState) + isTracking.value = false } onMounted(() => runOnMount && start()) diff --git a/src/components/useMouse/useMouse.spec.ts b/src/components/useMouse/useMouse.spec.ts index 6061eae..c2695de 100755 --- a/src/components/useMouse/useMouse.spec.ts +++ b/src/components/useMouse/useMouse.spec.ts @@ -21,7 +21,6 @@ const testComponent = () => ({ describe('useMouse', () => { it('should call document.addEventListener', async () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener') - expect(addEventListenerSpy).not.toHaveBeenCalled() const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') const wrapper = mount(testComponent()) await wrapper.vm.$nextTick() diff --git a/src/components/useMouseElement/useMouseElement.spec.ts b/src/components/useMouseElement/useMouseElement.spec.ts index c1f3e7d..2e0c202 100755 --- a/src/components/useMouseElement/useMouseElement.spec.ts +++ b/src/components/useMouseElement/useMouseElement.spec.ts @@ -38,7 +38,6 @@ const testComponent = () => ({ describe('useMouseElement', () => { it('should call document.addEventListener', async () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener') - expect(addEventListenerSpy).not.toHaveBeenCalled() const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') const wrapper = mount(testComponent()) await wrapper.vm.$nextTick() diff --git a/src/components/useSearchParams/index.ts b/src/components/useSearchParams/index.ts new file mode 100755 index 0000000..7b84462 --- /dev/null +++ b/src/components/useSearchParams/index.ts @@ -0,0 +1 @@ +export * from './useSearchParams' diff --git a/src/components/useSearchParams/stories/Field.vue b/src/components/useSearchParams/stories/Field.vue new file mode 100755 index 0000000..497d941 --- /dev/null +++ b/src/components/useSearchParams/stories/Field.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/useSearchParams/stories/UseSearchParamsDemo.vue b/src/components/useSearchParams/stories/UseSearchParamsDemo.vue new file mode 100755 index 0000000..336f070 --- /dev/null +++ b/src/components/useSearchParams/stories/UseSearchParamsDemo.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/components/useSearchParams/stories/useSearchParams.md b/src/components/useSearchParams/stories/useSearchParams.md new file mode 100755 index 0000000..10f4712 --- /dev/null +++ b/src/components/useSearchParams/stories/useSearchParams.md @@ -0,0 +1,70 @@ +# useSearchParams + +Vue function that tracks browser's location search parameters. + +## Reference + +```typescript +function useSearchParams( + parameters: string[], + runOnMount?: boolean +): { + searchParams: Ref + isTracking: Ref + start: () => void + stop: () => void +} +``` + +### Parameters + +- `parameters: string[]` the list of parameters you wish to track +- `runOnMount: boolean` whether to run the location search parameters tracking on mount, `true` by default + +### Returns + +- `searchParams: Ref` the object containing the search parameters as key value pairs +- `isTracking: Ref` whether the function is tracking the user's location search parameters or not +- `start: Function` the function used to start tracking the location search parameters +- `stop: Function` the function used to stop tracking the location search parameters + +## Usage + +```html + + + +``` diff --git a/src/components/useSearchParams/stories/useSearchParams.story.ts b/src/components/useSearchParams/stories/useSearchParams.story.ts new file mode 100755 index 0000000..afaa36d --- /dev/null +++ b/src/components/useSearchParams/stories/useSearchParams.story.ts @@ -0,0 +1,38 @@ +import { storiesOf } from '@storybook/vue' +import path from 'path' +import StoryTitle from '@src/helpers/StoryTitle.vue' +import UseSearchParamsDemo from './UseSearchParamsDemo.vue' + +const functionName = 'useSearchParams' +const functionPath = path.resolve(__dirname, '..') +const notes = require(`./${functionName}.md`).default + +const basicDemo = () => ({ + components: { StoryTitle, demo: UseSearchParamsDemo }, + template: ` +
+ + + + + +
` +}) + +storiesOf('sensors|useSearchParams', module) + .addParameters({ notes }) + .add('Demo', basicDemo) diff --git a/src/components/useSearchParams/useSearchParams.spec.ts b/src/components/useSearchParams/useSearchParams.spec.ts new file mode 100755 index 0000000..24eab2d --- /dev/null +++ b/src/components/useSearchParams/useSearchParams.spec.ts @@ -0,0 +1,105 @@ +import { mount } from '@src/helpers/test' +import { useSearchParams } from '@src/vue-use-kit' + +afterEach(() => { + jest.clearAllMocks() +}) + +const testComponent = (onMount = true) => ({ + template: ` +
+
+
{{JSON.stringify(searchParams)}}
+ + +
+ `, + setup() { + const { searchParams, isTracking, start, stop } = useSearchParams( + ['search', 'filter'], + onMount + ) + return { searchParams, isTracking, start, stop } + } +}) + +describe('useSearchParams', () => { + const params = { + search: 'gattus', + filter: '[ga, tt, us]', + nottracked: 'topus' + } + const events = ['popstate', 'pushstate', 'replacestate'] + + it('should call popstate, pushstate and replacestate onMounted', async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener') + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') + const wrapper = mount(testComponent()) + await wrapper.vm.$nextTick() + expect(addEventListenerSpy).toHaveBeenCalledTimes(events.length) + events.forEach(event => { + expect(addEventListenerSpy).toBeCalledWith(event, expect.any(Function)) + }) + + // Destroy instance to check if the remove event listener is being called + wrapper.destroy() + expect(removeEventListenerSpy).toHaveBeenCalledTimes(events.length) + events.forEach(event => { + expect(removeEventListenerSpy).toBeCalledWith(event, expect.any(Function)) + }) + }) + + it('should call document.addEventListener again when start is called', async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener') + const wrapper = mount(testComponent()) + expect(addEventListenerSpy).toHaveBeenCalledTimes(events.length) + wrapper.find('#stop').trigger('click') + + // Wait for Vue to append #start in the DOM + await wrapper.vm.$nextTick() + wrapper.find('#start').trigger('click') + expect(addEventListenerSpy).toHaveBeenCalledTimes(events.length * 2) + }) + + it('should call document.removeEventListener when stop is called', async () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') + const wrapper = mount(testComponent()) + wrapper.find('#stop').trigger('click') + + // Wait for Vue to append #start in the DOM + await wrapper.vm.$nextTick() + expect(removeEventListenerSpy).toHaveBeenCalledTimes(events.length) + }) + + it('should show #isTracking when onMount is true', async () => { + const wrapper = mount(testComponent(true)) + await wrapper.vm.$nextTick() + expect(wrapper.find('#isTracking').exists()).toBe(true) + }) + + it('should not show #isTracking when onMount is false', async () => { + const wrapper = mount(testComponent(false)) + await wrapper.vm.$nextTick() + expect(wrapper.find('#isTracking').exists()).toBe(false) + }) + + it('should display the searchParams object with all parameter keys', async () => { + const wrapper = mount(testComponent(true)) + await wrapper.vm.$nextTick() + expect(wrapper.text().includes('search')).toBe(true) + expect(wrapper.text().includes('filter')).toBe(true) + }) + + it('should display the searchParams object with all parameter values, except nottracked', async () => { + const wrapper = mount(testComponent(true)) + history.pushState( + {}, + '', + `${window.location.origin}?search=${params.search}&filter=${params.filter}¬tracked=${params.nottracked}` + ) + await wrapper.vm.$nextTick() + expect(wrapper.text().includes(params.search)).toBe(true) + expect(wrapper.text().includes(params.filter)).toBe(true) + expect(wrapper.text().includes(params.nottracked)).toBe(false) + }) +}) diff --git a/src/components/useSearchParams/useSearchParams.ts b/src/components/useSearchParams/useSearchParams.ts new file mode 100755 index 0000000..cf01693 --- /dev/null +++ b/src/components/useSearchParams/useSearchParams.ts @@ -0,0 +1,53 @@ +import { ref, onMounted, onUnmounted } from '@src/api' +import { patchHistoryMethodsOnce, normalizeEntriesData } from '@src/utils' + +const normalizeParams = (urlParamsObj: { [key: string]: string }) => ( + paramAcc: any, + param: string +) => { + paramAcc[param] = param in urlParamsObj ? urlParamsObj[param] : null + return paramAcc +} + +const getUrlParams = (parameters: string[]) => { + const urlParamsObj = normalizeEntriesData( + Array.from(new URLSearchParams(location.search).entries()) + ) + return parameters.reduce(normalizeParams(urlParamsObj), {}) +} + +export function useSearchParams( + parameters: T[], + runOnMount = true +) { + const searchParams = ref( + parameters.reduce(normalizeParams({}), {} as { [key in T]: any }) + ) + const isTracking = ref(false) + + const handleParamsChange = () => + (searchParams.value = getUrlParams(parameters)) + + const start = () => { + if (isTracking.value) return + patchHistoryMethodsOnce() + handleParamsChange() + window.addEventListener('popstate', handleParamsChange) + window.addEventListener('pushstate', handleParamsChange) + window.addEventListener('replacestate', handleParamsChange) + isTracking.value = true + } + + const stop = () => { + if (!isTracking.value) return + window.removeEventListener('popstate', handleParamsChange) + window.removeEventListener('pushstate', handleParamsChange) + window.removeEventListener('replacestate', handleParamsChange) + isTracking.value = false + } + + onMounted(() => runOnMount && start()) + onUnmounted(stop) + + return { searchParams, isTracking, start, stop } +} diff --git a/src/components/useTimeoutFn/stories/useTimeoutFn.md b/src/components/useTimeoutFn/stories/useTimeoutFn.md index f13f5d2..8d340c3 100755 --- a/src/components/useTimeoutFn/stories/useTimeoutFn.md +++ b/src/components/useTimeoutFn/stories/useTimeoutFn.md @@ -29,7 +29,7 @@ function useTimeoutFn( - `true` when the timer is completed - `null` when the timer is idle - `start: Function` the function used for starting or resetting the timer -- `stop: Function` the function used to stop the timer +- `stop: Function` the function used for stopping the timer ## Usage diff --git a/src/utils.ts b/src/utils.ts new file mode 100755 index 0000000..61cd5fe --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,27 @@ +// The history methods 'pushState' and 'replaceState' by default do not fire an event +// unless it is coming from user interaction with the browser navigation bar, +// so we are adding a patch to make them detectable +let isPatched = false +export const patchHistoryMethodsOnce = () => { + if (isPatched) return + const methods = ['pushState', 'replaceState'] + methods.forEach(method => { + const original = (history as any)[method] + ;(history as any)[method] = function(state: any) { + // eslint-disable-next-line prefer-rest-params + const result = original.apply(this, arguments) + const event = new Event(method.toLowerCase()) + ;(event as any).state = state + window.dispatchEvent(event) + return result + } + }) + + isPatched = true +} + +export const normalizeEntriesData = (data: [any, any][]) => + data.reduce((acc, [key, val]) => { + acc[key] = val + return acc + }, {} as { [key: string]: any }) diff --git a/src/vue-use-kit.ts b/src/vue-use-kit.ts index 1291a7b..3aab9d3 100755 --- a/src/vue-use-kit.ts +++ b/src/vue-use-kit.ts @@ -11,6 +11,7 @@ export * from './components/useLocation' export * from './components/useMedia' export * from './components/useMouse' export * from './components/useMouseElement' +export * from './components/useSearchParams' export * from './components/useIntervalFn' export * from './components/useInterval' diff --git a/tsconfig.json b/tsconfig.json index 865bb39..e6ec554 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "moduleResolution": "node", "target": "es5", "module": "es2015", - "lib": ["es2015", "es2016", "es2017", "dom"], + "lib": ["es2015", "es2016", "es2017", "dom", "dom.iterable"], "strict": true, "sourceMap": true, "declaration": true,