feat(vue): extend useIonRouter hook for programmatic navigation with animation control (#23499)

resolves #23450
This commit is contained in:
Liam DeBeasi
2021-06-28 10:33:32 -04:00
committed by GitHub
parent 79e3a26499
commit fc9e1b4b36
10 changed files with 456 additions and 132 deletions

View File

@ -32,6 +32,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
* [Tabs Config](#tabs-config) * [Tabs Config](#tabs-config)
* [Tabs Router Outlet](#tabs-router-outlet) * [Tabs Router Outlet](#tabs-router-outlet)
* [Overlay Events](#overlay-events) * [Overlay Events](#overlay-events)
* [Utility Function Types](#utility-function-types)
- [Browser and Platform Support](#browser-and-platform-support) - [Browser and Platform Support](#browser-and-platform-support)
@ -309,6 +310,12 @@ This applies to the following components: `ion-action-sheet`, `ion-alert`, `ion-
</ion-modal> </ion-modal>
``` ```
#### 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 ### Browser and Platform Support

View File

@ -2,7 +2,8 @@ import {
parseQuery, parseQuery,
Router, Router,
RouteLocationNormalized, RouteLocationNormalized,
NavigationFailure NavigationFailure,
RouteLocationRaw
} from 'vue-router'; } from 'vue-router';
import { createLocationHistory } from './locationHistory'; import { createLocationHistory } from './locationHistory';
import { generateId } from './utils'; 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) => { const handleNavigate = (path: RouteLocationRaw, routerAction?: RouteAction, routerDirection?: RouteDirection, routerAnimation?: AnimationBuilder, tab?: string) => {
incomingRouteParams = { setIncomingRouteParams(routerAction, routerDirection, routerAnimation, tab);
routerAction,
routerDirection,
routerAnimation,
tab
}
if (routerAction === 'push') { if (routerAction === 'push') {
router.push(path); router.push(path);
@ -247,11 +243,7 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) =>
const navigate = (navigationOptions: ExternalNavigationOptions) => { const navigate = (navigationOptions: ExternalNavigationOptions) => {
const { routerAnimation, routerDirection, routerLink } = navigationOptions; const { routerAnimation, routerDirection, routerLink } = navigationOptions;
incomingRouteParams = { setIncomingRouteParams('push', routerDirection, routerAnimation);
routerAnimation,
routerDirection: routerDirection || 'forward',
routerAction: 'push'
}
router.push(routerLink); router.push(routerLink);
} }
@ -313,7 +305,26 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) =>
historyChangeListeners.push(cb); 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 { return {
handleNavigate,
handleNavigateBack, handleNavigateBack,
handleSetCurrentTab, handleSetCurrentTab,
getCurrentRouteInfo, getCurrentRouteInfo,
@ -321,6 +332,8 @@ export const createIonRouter = (opts: IonicVueRouterOptions, router: Router) =>
navigate, navigate,
resetTab, resetTab,
changeTab, changeTab,
registerHistoryChangeListener registerHistoryChangeListener,
goBack,
goForward
} }
} }

View File

@ -63,6 +63,7 @@ export interface ExternalNavigationOptions {
routerLink: string; routerLink: string;
routerDirection?: RouteDirection; routerDirection?: RouteDirection;
routerAnimation?: AnimationBuilder; routerAnimation?: AnimationBuilder;
routerAction?: RouteAction;
} }
export interface NavigationInformation { export interface NavigationInformation {

View File

@ -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<any> | void | null;
export interface IonRouter {
canGoBack: (deep?: number) => boolean;
}
export interface IonKeyboardRef {
isOpen: Ref<boolean>;
keyboardHeight: Ref<number>;
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 = <T extends Function = () => 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);

View File

@ -0,0 +1,15 @@
import { BackButtonEvent } from '@ionic/core/components';
type Handler = (processNextHandler: () => void) => Promise<any> | 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 };
}

View File

@ -0,0 +1,40 @@
import { ref, Ref } from 'vue';
export interface UseKeyboardResult {
isOpen: Ref<boolean>;
keyboardHeight: Ref<number>;
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
}
}

View File

@ -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 = <T extends Function = () => 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);

View File

@ -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
}

View File

@ -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'; import { arrowBackSharp, caretBackSharp, chevronBack, chevronDown, chevronForward, close, closeCircle, closeSharp, menuOutline, menuSharp, reorderThreeOutline, reorderTwoSharp, searchOutline, searchSharp } from 'ionicons/icons';
export * from './proxies'; 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 { IonicVue } from './ionic-vue';
export { IonBackButton } from './components/IonBackButton'; export { IonBackButton } from './components/IonBackButton';
@ -18,18 +24,6 @@ export { IonModal } from './components/IonModal';
export * from './components/Overlays'; export * from './components/Overlays';
export {
IonKeyboardRef,
IonRouter,
useBackButton,
useIonRouter,
useKeyboard,
onIonViewWillEnter,
onIonViewDidEnter,
onIonViewWillLeave,
onIonViewDidLeave
} from './hooks';
export { export {
modalController, modalController,
popoverController, popoverController,

View File

@ -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: '<ion-app><ion-router-outlet /></ion-app>',
}
const BasePage = {
template: '<ion-page></ion-page>',
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();
})
})