diff --git a/BREAKING.md b/BREAKING.md index 3fd13eaab5..ebff3744f4 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -32,6 +32,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver * [Tabs Config](#tabs-config) * [Tabs Router Outlet](#tabs-router-outlet) * [Overlay Events](#overlay-events) + * [Utility Function Types](#utility-function-types) - [Browser and Platform Support](#browser-and-platform-support) @@ -309,6 +310,12 @@ This applies to the following components: `ion-action-sheet`, `ion-alert`, `ion- ``` +#### Utility Function Types + +- The `IonRouter` type for `useIonRouter` has been renamed to `UseIonRouterResult`. + +- The `IonKeyboardRef` type for `useKeyboard` has been renamed to `UseKeyboardResult`. + ### Browser and Platform Support diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index dd6b67c0ea..8e7d1f275b 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -2,7 +2,8 @@ import { parseQuery, Router, RouteLocationNormalized, - NavigationFailure + NavigationFailure, + RouteLocationRaw } from 'vue-router'; import { createLocationHistory } from './locationHistory'; import { generateId } from './utils'; @@ -115,13 +116,8 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) => } } - const handleNavigate = (path: string, routerAction?: RouteAction, routerDirection?: RouteDirection, routerAnimation?: AnimationBuilder, tab?: string) => { - incomingRouteParams = { - routerAction, - routerDirection, - routerAnimation, - tab - } + const handleNavigate = (path: RouteLocationRaw, routerAction?: RouteAction, routerDirection?: RouteDirection, routerAnimation?: AnimationBuilder, tab?: string) => { + setIncomingRouteParams(routerAction, routerDirection, routerAnimation, tab); if (routerAction === 'push') { router.push(path); @@ -247,11 +243,7 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) => const navigate = (navigationOptions: ExternalNavigationOptions) => { const { routerAnimation, routerDirection, routerLink } = navigationOptions; - incomingRouteParams = { - routerAnimation, - routerDirection: routerDirection || 'forward', - routerAction: 'push' - } + setIncomingRouteParams('push', routerDirection, routerAnimation); router.push(routerLink); } @@ -313,7 +305,26 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) => historyChangeListeners.push(cb); } + const setIncomingRouteParams = (routerAction: RouteAction = 'push', routerDirection: RouteDirection = 'forward', routerAnimation?: AnimationBuilder, tab?: string) => { + incomingRouteParams = { + routerAction, + routerDirection, + routerAnimation, + tab + }; + } + + const goBack = (routerAnimation?: AnimationBuilder) => { + setIncomingRouteParams('pop', 'back', routerAnimation); + router.back() + }; + const goForward = (routerAnimation?: AnimationBuilder) => { + setIncomingRouteParams('push', 'forward', routerAnimation); + router.forward(); + } + return { + handleNavigate, handleNavigateBack, handleSetCurrentTab, getCurrentRouteInfo, @@ -321,6 +332,8 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) => navigate, resetTab, changeTab, - registerHistoryChangeListener + registerHistoryChangeListener, + goBack, + goForward } } diff --git a/packages/vue-router/src/types.ts b/packages/vue-router/src/types.ts index 74d41e5e68..fd9143fadd 100644 --- a/packages/vue-router/src/types.ts +++ b/packages/vue-router/src/types.ts @@ -63,6 +63,7 @@ export interface ExternalNavigationOptions { routerLink: string; routerDirection?: RouteDirection; routerAnimation?: AnimationBuilder; + routerAction?: RouteAction; } export interface NavigationInformation { diff --git a/packages/vue/src/hooks.ts b/packages/vue/src/hooks.ts deleted file mode 100644 index f128b9e8ef..0000000000 --- a/packages/vue/src/hooks.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { BackButtonEvent } from '@ionic/core/components'; -import { - inject, - ref, - Ref, - ComponentInternalInstance, - getCurrentInstance -} from 'vue'; -import { LifecycleHooks } from './utils'; - -type Handler = (processNextHandler: () => void) => Promise | void | null; - -export interface IonRouter { - canGoBack: (deep?: number) => boolean; -} - -export interface IonKeyboardRef { - isOpen: Ref; - keyboardHeight: Ref; - unregister: () => void -} - -export const useBackButton = (priority: number, handler: Handler) => { - const callback = (ev: BackButtonEvent) => ev.detail.register(priority, handler); - const unregister = () => document.removeEventListener('ionBackButton', callback); - - document.addEventListener('ionBackButton', callback); - - return { unregister }; -} - -export const useIonRouter = (): IonRouter => { - const { canGoBack } = inject('navManager') as any; - - return { - canGoBack - } as IonRouter -} - -export const useKeyboard = (): IonKeyboardRef => { - let isOpen = ref(false); - let keyboardHeight = ref(0); - - const showCallback = (ev: CustomEvent) => { - isOpen.value = true; - keyboardHeight.value = ev.detail.keyboardHeight; - } - - const hideCallback = () => { - isOpen.value = false; - keyboardHeight.value = 0; - } - - const unregister = () => { - if (typeof (window as any) !== 'undefined') { - window.removeEventListener('ionKeyboardDidShow', showCallback); - window.removeEventListener('ionKeyboardDidHide', hideCallback); - } - } - - if (typeof (window as any) !== 'undefined') { - window.addEventListener('ionKeyboardDidShow', showCallback); - window.addEventListener('ionKeyboardDidHide', hideCallback); - } - - return { - isOpen, - keyboardHeight, - unregister - } -} - -/** - * Creates an returns a function that - * can be used to provide a lifecycle hook. - */ -const injectHook = (lifecycleType: LifecycleHooks, hook: Function, component: ComponentInternalInstance | null): Function | undefined => { - if (component) { - - // Add to public instance so it is accessible to IonRouterOutlet - const target = component as any; - const hooks = target.proxy[lifecycleType] || (target.proxy[lifecycleType] = []); - const wrappedHook = (...args: unknown[]) => { - if (target.isUnmounted) { - return; - } - - return args ? hook(...args) : hook(); - }; - - hooks.push(wrappedHook); - - return wrappedHook; - } else { - console.warn('[@ionic/vue]: Ionic Lifecycle Hooks can only be used during execution of setup().'); - } -} - -const createHook = any>(lifecycle: LifecycleHooks) => { - return (hook: T, target: ComponentInternalInstance | null = getCurrentInstance()) => injectHook(lifecycle, hook, target); -} - -export const onIonViewWillEnter = createHook(LifecycleHooks.WillEnter); -export const onIonViewDidEnter = createHook(LifecycleHooks.DidEnter); -export const onIonViewWillLeave = createHook(LifecycleHooks.WillLeave); -export const onIonViewDidLeave = createHook(LifecycleHooks.DidLeave); diff --git a/packages/vue/src/hooks/back-button.ts b/packages/vue/src/hooks/back-button.ts new file mode 100644 index 0000000000..5a79c765b6 --- /dev/null +++ b/packages/vue/src/hooks/back-button.ts @@ -0,0 +1,15 @@ +import { BackButtonEvent } from '@ionic/core/components'; + +type Handler = (processNextHandler: () => void) => Promise | void | null; +export interface UseBackButtonResult { + unregister: () => void; +} + +export const useBackButton = (priority: number, handler: Handler): UseBackButtonResult => { + const callback = (ev: BackButtonEvent) => ev.detail.register(priority, handler); + const unregister = () => document.removeEventListener('ionBackButton', callback); + + document.addEventListener('ionBackButton', callback); + + return { unregister }; +} diff --git a/packages/vue/src/hooks/keyboard.ts b/packages/vue/src/hooks/keyboard.ts new file mode 100644 index 0000000000..627702e12e --- /dev/null +++ b/packages/vue/src/hooks/keyboard.ts @@ -0,0 +1,40 @@ +import { ref, Ref } from 'vue'; + +export interface UseKeyboardResult { + isOpen: Ref; + keyboardHeight: Ref; + unregister: () => void +} + +export const useKeyboard = (): UseKeyboardResult => { + let isOpen = ref(false); + let keyboardHeight = ref(0); + + const showCallback = (ev: CustomEvent) => { + isOpen.value = true; + keyboardHeight.value = ev.detail.keyboardHeight; + } + + const hideCallback = () => { + isOpen.value = false; + keyboardHeight.value = 0; + } + + const unregister = () => { + if (typeof (window as any) !== 'undefined') { + window.removeEventListener('ionKeyboardDidShow', showCallback); + window.removeEventListener('ionKeyboardDidHide', hideCallback); + } + } + + if (typeof (window as any) !== 'undefined') { + window.addEventListener('ionKeyboardDidShow', showCallback); + window.addEventListener('ionKeyboardDidHide', hideCallback); + } + + return { + isOpen, + keyboardHeight, + unregister + } +} diff --git a/packages/vue/src/hooks/lifecycle.ts b/packages/vue/src/hooks/lifecycle.ts new file mode 100644 index 0000000000..476551891a --- /dev/null +++ b/packages/vue/src/hooks/lifecycle.ts @@ -0,0 +1,37 @@ +import { LifecycleHooks } from '../utils'; +import { ComponentInternalInstance, getCurrentInstance } from 'vue'; + +/** + * Creates an returns a function that + * can be used to provide a lifecycle hook. + */ +const injectHook = (lifecycleType: LifecycleHooks, hook: Function, component: ComponentInternalInstance | null): Function | undefined => { + if (component) { + + // Add to public instance so it is accessible to IonRouterOutlet + const target = component as any; + const hooks = target.proxy[lifecycleType] || (target.proxy[lifecycleType] = []); + const wrappedHook = (...args: unknown[]) => { + if (target.isUnmounted) { + return; + } + + return args ? hook(...args) : hook(); + }; + + hooks.push(wrappedHook); + + return wrappedHook; + } else { + console.warn('[@ionic/vue]: Ionic Lifecycle Hooks can only be used during execution of setup().'); + } +} + +const createHook = any>(lifecycle: LifecycleHooks) => { + return (hook: T, target: ComponentInternalInstance | null = getCurrentInstance()) => injectHook(lifecycle, hook, target); +} + +export const onIonViewWillEnter = createHook(LifecycleHooks.WillEnter); +export const onIonViewDidEnter = createHook(LifecycleHooks.DidEnter); +export const onIonViewWillLeave = createHook(LifecycleHooks.WillLeave); +export const onIonViewDidLeave = createHook(LifecycleHooks.DidLeave); diff --git a/packages/vue/src/hooks/router.ts b/packages/vue/src/hooks/router.ts new file mode 100644 index 0000000000..27cf52408a --- /dev/null +++ b/packages/vue/src/hooks/router.ts @@ -0,0 +1,69 @@ +import { inject } from 'vue'; +import { AnimationBuilder } from '../'; + +export type RouteAction = 'push' | 'pop' | 'replace'; +export type RouteDirection = 'forward' | 'back' | 'root' | 'none'; + +export interface UseIonRouterResult { + + /** + * The location parameter is really of type 'RouteLocationRaw' + * imported from vue-router, but the @ionic/vue package should + * not have a hard dependency on vue-router, so we just use 'any'. + */ + canGoBack: (deep?: number) => boolean; + push: (location: any, routerAnimation?: AnimationBuilder) => void; + replace: (location: any, routerAnimation?: AnimationBuilder) => void; + back: (routerAnimation?: AnimationBuilder) => void; + forward: (routerAnimation?: AnimationBuilder) => void; + navigate: ( + location: any, + routerDirection?: RouteDirection, + routerAction?: RouteAction, + routerAnimation?: AnimationBuilder + ) => void; +} + +/** + * Used to navigate within Vue Router + * while controlling the animation. + */ +export const useIonRouter = (): UseIonRouterResult => { + const { canGoBack, goBack, goForward, handleNavigate } = inject('navManager') as any; + + const navigate = ( + location: any, + routerDirection?: RouteDirection, + routerAction?: RouteAction, + routerAnimation?: AnimationBuilder + ) => handleNavigate(location, routerAction, routerDirection, routerAnimation); + + const push = ( + location: any, + routerAnimation?: AnimationBuilder + ) => navigate(location, 'forward', 'push', routerAnimation); + + const replace = ( + location: any, + routerAnimation?: AnimationBuilder + ) => navigate(location, 'root', 'replace', routerAnimation); + + const back = ( + routerAnimation?: AnimationBuilder + ) => goBack(routerAnimation); + + const forward = ( + routerAnimation?: AnimationBuilder + ) => goForward(routerAnimation); + + return { + canGoBack, + push, + replace, + back, + forward, + navigate + } as UseIonRouterResult +} + + diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index de3fa2827a..a7fb9212e4 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -2,6 +2,12 @@ import { addIcons } from 'ionicons'; import { arrowBackSharp, caretBackSharp, chevronBack, chevronDown, chevronForward, close, closeCircle, closeSharp, menuOutline, menuSharp, reorderThreeOutline, reorderTwoSharp, searchOutline, searchSharp } from 'ionicons/icons'; export * from './proxies'; + +export { UseBackButtonResult, useBackButton } from './hooks/back-button'; +export { UseKeyboardResult, useKeyboard } from './hooks/keyboard'; +export { onIonViewWillEnter, onIonViewDidEnter, onIonViewWillLeave, onIonViewDidLeave } from './hooks/lifecycle'; +export { UseIonRouterResult, useIonRouter } from './hooks/router'; + export { IonicVue } from './ionic-vue'; export { IonBackButton } from './components/IonBackButton'; @@ -18,18 +24,6 @@ export { IonModal } from './components/IonModal'; export * from './components/Overlays'; -export { - IonKeyboardRef, - IonRouter, - useBackButton, - useIonRouter, - useKeyboard, - onIonViewWillEnter, - onIonViewDidEnter, - onIonViewWillLeave, - onIonViewDidLeave -} from './hooks'; - export { modalController, popoverController, diff --git a/packages/vue/test-app/tests/unit/hooks.spec.ts b/packages/vue/test-app/tests/unit/hooks.spec.ts new file mode 100644 index 0000000000..544df5b456 --- /dev/null +++ b/packages/vue/test-app/tests/unit/hooks.spec.ts @@ -0,0 +1,254 @@ +import { mount } from '@vue/test-utils'; +import { createRouter, createWebHistory } from '@ionic/vue-router'; +import { IonicVue, IonApp, IonRouterOutlet, IonPage, useIonRouter, createAnimation } from '@ionic/vue'; +import { waitForRouter } from './utils'; + +const App = { + components: { IonApp, IonRouterOutlet }, + template: '', +} + +const BasePage = { + template: '', + components: { IonPage }, +} + +describe('useIonRouter', () => { + beforeAll(() => { + (HTMLElement.prototype as HTMLIonRouterOutletElement).commit = jest.fn((entering, leaving, opts) => { + if (opts && opts.animationBuilder) { + opts.animationBuilder(entering, leaving); + } + + return Promise.resolve(true); + }); + }); + it('should correctly navigate back', async () => { + const Page1 = { + ...BasePage + }; + + const Page2 = { + ...BasePage, + name: 'Page2', + setup() { + const ionRouter = useIonRouter(); + + return { ionRouter }; + } + }; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', component: Page1 }, + { path: '/page2', component: Page2 } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/page2'); + await waitForRouter(); + + const cmp = wrapper.findComponent(Page2); + const vm = cmp.vm as any; + const animFn = jest.fn(); + + vm.ionRouter.back(animFn); + await waitForRouter(); + + expect(router.currentRoute.value.path).toEqual('/'); + expect(animFn).toHaveBeenCalled(); + }); + + it('should correctly navigate forward', async () => { + const Page1 = { + ...BasePage + }; + + const Page2 = { + ...BasePage, + name: 'Page2', + setup() { + const ionRouter = useIonRouter(); + + return { ionRouter }; + } + }; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', component: Page1 }, + { path: '/page2', component: Page2 } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/page2'); + await waitForRouter(); + + const cmp = wrapper.findComponent(Page2); + const vm = cmp.vm as any; + const animFn = jest.fn(); + + vm.ionRouter.back(); + await waitForRouter(); + + expect(router.currentRoute.value.path).toEqual('/'); + + vm.ionRouter.forward(animFn); + await waitForRouter(); + + expect(router.currentRoute.value.path).toEqual('/page2'); + expect(animFn).toHaveBeenCalled(); + }) + + it('should correctly push a page', async () => { + const Page1 = { + ...BasePage, + name: 'Page1', + setup() { + const ionRouter = useIonRouter(); + + return { ionRouter }; + } + }; + + const Page2 = { + ...BasePage, + name: 'Page2', + + }; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', component: Page1 }, + { path: '/page2', component: Page2 } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const cmp = wrapper.findComponent(Page1); + const vm = cmp.vm as any; + const animFn = jest.fn(); + + vm.ionRouter.push('/page2', animFn); + await waitForRouter(); + + expect(router.currentRoute.value.path).toEqual('/page2'); + expect(animFn).toHaveBeenCalled(); + }); + + it('should correctly replace a page', async () => { + const Page1 = { + ...BasePage, + name: 'Page1', + setup() { + const ionRouter = useIonRouter(); + + return { ionRouter }; + } + }; + + const Page2 = { + ...BasePage, + name: 'Page2', + + }; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', component: Page1 }, + { path: '/page2', component: Page2 } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const cmp = wrapper.findComponent(Page1); + const vm = cmp.vm as any; + const animFn = jest.fn(); + + vm.ionRouter.replace('/page2', animFn); + await waitForRouter(); + + expect(router.currentRoute.value.path).toEqual('/page2'); + expect(animFn).toHaveBeenCalled(); + + expect(vm.ionRouter.canGoBack()).toEqual(false); + }) + + it('should correctly navigate', async () => { + const Page1 = { + ...BasePage, + name: 'Page1', + setup() { + const ionRouter = useIonRouter(); + + return { ionRouter }; + } + }; + + const Page2 = { + ...BasePage, + name: 'Page2', + + }; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', component: Page1 }, + { path: '/page2', component: Page2 } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const cmp = wrapper.findComponent(Page1); + const vm = cmp.vm as any; + const animFn = jest.fn(); + + vm.ionRouter.navigate('/page2', 'forward', 'push', animFn); + await waitForRouter(); + + expect(router.currentRoute.value.path).toEqual('/page2'); + expect(animFn).toHaveBeenCalled(); + }) +})