diff --git a/README.md b/README.md index 4d72ead..5242b6b 100755 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ Vue.use(VueCompositionAPI); ## APIs - Sensors + - [`useGeolocation`](./src/components/useGeolocation/stories/useGeolocation.md) — tracks geolocation state of user's device. + [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-usegeolocation--demo) - [`useHover`](./src/components/useHover/stories/useHover.md) — tracks mouse hover state of a given element. [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-usehover--demo) - - [`useIntersection`](./src/components/useIntersection/stories/useIntersection.md) — tracks intersection of target element with an ancestor element. + - [`useIntersection`](./src/components/useIntersection/stories/useIntersection.md) — tracks intersection of target element with an ancestor element. [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-useintersection--demo) [![Demo](https://img.shields.io/badge/advanced_demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/sensors-useintersection--advanced-demo) - [`useMedia`](./src/components/useMedia/stories/useMedia.md) — tracks state of a CSS media query. diff --git a/src/components/useFullscreen/useFullscreen.spec.ts b/src/components/useFullscreen/useFullscreen.spec.ts index 7796818..52850ec 100755 --- a/src/components/useFullscreen/useFullscreen.spec.ts +++ b/src/components/useFullscreen/useFullscreen.spec.ts @@ -21,7 +21,7 @@ const testComponent = () => ({ }) describe('useFullscreen', () => { - it('should not be fullscreen when initialized', () => { + it('should not be fullscreen onMounted', () => { const wrapper = mount(testComponent()) expect(wrapper.find('#isFullscreen').exists()).toBe(false) }) diff --git a/src/components/useGeolocation/index.ts b/src/components/useGeolocation/index.ts new file mode 100755 index 0000000..2b68ac8 --- /dev/null +++ b/src/components/useGeolocation/index.ts @@ -0,0 +1 @@ +export * from './useGeolocation' diff --git a/src/components/useGeolocation/stories/UseGeolocationDemo.vue b/src/components/useGeolocation/stories/UseGeolocationDemo.vue new file mode 100755 index 0000000..d8be26c --- /dev/null +++ b/src/components/useGeolocation/stories/UseGeolocationDemo.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/useGeolocation/stories/useGeolocation.md b/src/components/useGeolocation/stories/useGeolocation.md new file mode 100755 index 0000000..0058731 --- /dev/null +++ b/src/components/useGeolocation/stories/useGeolocation.md @@ -0,0 +1,74 @@ +# useGeolocation + +Vue function that tracks geolocation state of user's device, based on the [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API). + +## Reference + +```typescript +interface UseGeolocation { + loading: boolean + accuracy: number | null + altitude: number | null + altitudeAccuracy: number | null + heading: number | null + latitude: number | null + longitude: number | null + speed: number | null + timestamp: number | null + error?: Error | PositionError +} +``` + +```typescript +useGeolocation( + options?: PositionOptions, + runOnMount?: boolean +): { + isTracking: Ref; + geo: Ref; + start: () => void; + stop: () => void; +} +``` + +### Parameters + +- `options: PositionOptions` the [geolocation position options](https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions) +- `runOnMount: boolean` whether to run the geolocation tracking on mount, `true` by default + +### Returns + +- `isTracking: Ref` whether the function is tracking the user's location or not +- `geo: Ref` the geolocation object +- `start: Function` the function used for starting the geolocation tracking +- `stop: Function` the function used for stopping the geolocation tracking + +## Usage + +```html + + + +``` diff --git a/src/components/useGeolocation/stories/useGeolocation.story.ts b/src/components/useGeolocation/stories/useGeolocation.story.ts new file mode 100755 index 0000000..bdf9caf --- /dev/null +++ b/src/components/useGeolocation/stories/useGeolocation.story.ts @@ -0,0 +1,28 @@ +import { storiesOf } from '@storybook/vue' +import path from 'path' +import StoryTitle from '@src/helpers/StoryTitle.vue' +import UseGeolocationDemo from './UseGeolocationDemo.vue' + +const functionName = 'useGeolocation' +const functionPath = path.resolve(__dirname, '..') +const notes = require(`./${functionName}.md`).default + +const basicDemo = () => ({ + components: { StoryTitle, demo: UseGeolocationDemo }, + template: ` +
+ + + + + +
` +}) + +storiesOf('sensors|useGeolocation', module) + .addParameters({ notes }) + .add('Demo', basicDemo) diff --git a/src/components/useGeolocation/useGeolocation.spec.ts b/src/components/useGeolocation/useGeolocation.spec.ts new file mode 100755 index 0000000..29d4862 --- /dev/null +++ b/src/components/useGeolocation/useGeolocation.spec.ts @@ -0,0 +1,83 @@ +import { mount } from '@src/helpers/test' +import { useGeolocation } from '@src/vue-use-kit' + +let watchPosition: any +let clearWatch: any +let getCurrentPosition: any + +beforeEach(() => { + watchPosition = jest.fn() + clearWatch = jest.fn() + getCurrentPosition = jest.fn().mockImplementationOnce(success => + Promise.resolve( + success({ + coords: { + latitude: 51.1, + longitude: 45.3 + } + }) + ) + ) + const inst = ((navigator as any).geolocation = { + watchPosition, + clearWatch, + getCurrentPosition + }) +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const testComponent = () => ({ + template: ` +
+
+ + +
+ `, + setup() { + const { isTracking, geo, start, stop } = useGeolocation() + return { isTracking, geo, start, stop } + } +}) + +describe('useGeolocation', () => { + it('should call getCurrentPosition and watchPosition onMounted', () => { + expect(getCurrentPosition).toHaveBeenCalledTimes(0) + expect(watchPosition).toHaveBeenCalledTimes(0) + mount(testComponent()) + expect(getCurrentPosition).toHaveBeenCalledTimes(1) + expect(watchPosition).toHaveBeenCalledTimes(1) + }) + + it('should call clearWatch onUnmounted', () => { + expect(clearWatch).toHaveBeenCalledTimes(0) + const wrapper = mount(testComponent()) + wrapper.vm.$destroy() + expect(clearWatch).toHaveBeenCalledTimes(1) + }) + + it('should call getCurrentPosition again when start is called', async () => { + expect(getCurrentPosition).toHaveBeenCalledTimes(0) + const wrapper = mount(testComponent()) + expect(getCurrentPosition).toHaveBeenCalledTimes(1) + wrapper.find('#stop').trigger('click') + + // Wait for Vue to append #start in the DOM + await wrapper.vm.$nextTick() + wrapper.find('#start').trigger('click') + expect(getCurrentPosition).toHaveBeenCalledTimes(2) + }) + + it('should call clearWatch when stop is called', async () => { + expect(clearWatch).toHaveBeenCalledTimes(0) + const wrapper = mount(testComponent()) + wrapper.find('#stop').trigger('click') + + // Wait for Vue to append #start in the DOM + await wrapper.vm.$nextTick() + expect(clearWatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/useGeolocation/useGeolocation.ts b/src/components/useGeolocation/useGeolocation.ts new file mode 100755 index 0000000..4a283db --- /dev/null +++ b/src/components/useGeolocation/useGeolocation.ts @@ -0,0 +1,101 @@ +import { ref, onMounted, onUnmounted, Ref } from '@src/api' + +export interface UseGeolocation { + loading: boolean + accuracy: number | null + altitude: number | null + altitudeAccuracy: number | null + heading: number | null + latitude: number | null + longitude: number | null + speed: number | null + timestamp: number | null + error?: Error | PositionError +} + +const defaultOpts = { + enableHighAccuracy: false, + timeout: Infinity, + maximumAge: 0 +} + +export function useGeolocation( + options: PositionOptions = {}, + runOnMount = true +) { + options = Object.assign(defaultOpts, options) + + // Note: surprisingly the watchId can be 0 (not positive) so + // we have to check if watchId !== null every time + let watchId: number | null = null + + const geoInitData = { + loading: false, + accuracy: null, + altitude: null, + altitudeAccuracy: null, + heading: null, + latitude: null, + longitude: null, + speed: null, + timestamp: null + } + + const isTracking = ref(false) + const geo: Ref = ref({ ...geoInitData }) + + const onEventError = (error: PositionError) => { + if (watchId === null) return + geo.value.loading = false + geo.value.error = { + code: error.code, + message: error.message + } as PositionError + isTracking.value = false + } + + const handleGeolocation = ({ coords, timestamp }: Position) => { + geo.value = { + loading: false, + accuracy: coords.accuracy, + altitude: coords.altitude, + altitudeAccuracy: coords.altitudeAccuracy, + heading: coords.heading, + latitude: coords.latitude, + longitude: coords.longitude, + speed: coords.speed, + timestamp + } + isTracking.value = true + } + + const start = () => { + if (watchId !== null) return + geo.value.loading = true + geo.value.timestamp = Date.now() + + navigator.geolocation.getCurrentPosition( + handleGeolocation, + onEventError, + options + ) + + watchId = navigator.geolocation.watchPosition( + handleGeolocation, + onEventError, + options + ) + } + + const stop = () => { + if (watchId === null) return + navigator.geolocation.clearWatch(watchId) + watchId = null + isTracking.value = false + } + + onMounted(() => runOnMount && start()) + onUnmounted(stop) + + return { isTracking, geo, start, stop } +} diff --git a/src/components/useIntersection/useIntersection.spec.ts b/src/components/useIntersection/useIntersection.spec.ts index 8bd152d..2d1ea33 100755 --- a/src/components/useIntersection/useIntersection.spec.ts +++ b/src/components/useIntersection/useIntersection.spec.ts @@ -31,7 +31,7 @@ const testComponent = (onMount = true) => ({ }) describe('useIntersection', () => { - it('should call IntersectionObserver when initialized', () => { + it('should call IntersectionObserver onMounted', () => { expect(observe).toHaveBeenCalledTimes(0) mount(testComponent()) expect(observe).toHaveBeenCalledTimes(1) diff --git a/src/components/useIntervalFn/useIntervalFn.spec.ts b/src/components/useIntervalFn/useIntervalFn.spec.ts index cbefc18..9fc4b1a 100755 --- a/src/components/useIntervalFn/useIntervalFn.spec.ts +++ b/src/components/useIntervalFn/useIntervalFn.spec.ts @@ -33,7 +33,7 @@ const testComponent = (onMount = true) => ({ }) describe('useIntervalFn', () => { - it('should call setInterval when initialized', () => { + it('should call setInterval onMounted', () => { expect(setInterval).toHaveBeenCalledTimes(0) mount(testComponent()) jest.advanceTimersByTime(1500) diff --git a/src/components/useTimeoutFn/useTimeoutFn.spec.ts b/src/components/useTimeoutFn/useTimeoutFn.spec.ts index 3b4125b..0b28ed8 100755 --- a/src/components/useTimeoutFn/useTimeoutFn.spec.ts +++ b/src/components/useTimeoutFn/useTimeoutFn.spec.ts @@ -35,7 +35,7 @@ const testComponent = (onMount = true) => ({ }) describe('useTimeoutFn', () => { - it('should call setTimeout when initialized', () => { + it('should call setTimeout onMounted', () => { expect(setTimeout).toHaveBeenCalledTimes(0) mount(testComponent()) expect(setTimeout).toHaveBeenCalledTimes(1) diff --git a/src/vue-use-kit.ts b/src/vue-use-kit.ts index 8346781..89b4bbd 100755 --- a/src/vue-use-kit.ts +++ b/src/vue-use-kit.ts @@ -3,6 +3,7 @@ export * from './components/getQuery' export * from './components/useClickAway' export * from './components/useFullscreen' +export * from './components/useGeolocation' export * from './components/useHover' export * from './components/useIntersection' export * from './components/useMedia'