feat(vue): support for ion-tabs (#17678)

* Add ion-tabs support, QOL fixes

* Fix @ionic/core version, rebuild core to include docs

* Update router

* Add support for IonTabsWillChange and IonTabsDidChange events

* Update usage docs

* Merge core and user provided click handlers in ion-tab-button

* rename file to be consistent
This commit is contained in:
Michael Tintiuc
2019-03-22 16:56:53 +02:00
committed by Mike Hartington
parent 439b10e10d
commit ee7167512f
9 changed files with 347 additions and 171 deletions

View File

@ -0,0 +1,227 @@
import Vue, { CreateElement, RenderContext, VNode } from 'vue';
import { Route } from 'vue-router';
interface EventListeners {
[key: string]: Function | Function[];
}
const tabBars = [] as VNode[];
const cachedTabs = [] as VNode[];
export default {
name: 'IonTabs',
functional: true,
render(h: CreateElement, { parent, data, slots, listeners }: RenderContext) {
const renderQueue = [] as VNode[];
const postRenderQueue = [] as VNode[];
const route = parent.$route;
let selectedTab = '';
if (!parent.$router) {
throw new Error('IonTabs requires an instance of either VueRouter or IonicVueRouter');
}
// Loop through all of the children in the default slot
for (let i = 0; i < slots().default.length; i++) {
const vnode = slots().default[i];
// Not an ion-tab, push to render and post-render processing queues
if (!vnode.tag || vnode.tag.match(/ion-tab$/) === null) {
renderQueue.push(vnode);
postRenderQueue[i] = vnode;
continue;
}
const tabName = matchRouteToTab(vnode, route);
const tabIsCached = cachedTabs[i];
// Landed on tab route
// Cache the tab, push to render queue and continue iteration
if (tabName) {
if (!tabIsCached) {
cachedTabs[i] = vnode;
}
selectedTab = tabName;
vnode.data.attrs.active = true;
renderQueue.push(vnode);
continue;
}
// If tab was previously cached, push to render queue but don't activate
// Otherwise push an empty node
renderQueue.push(tabIsCached ? vnode : h());
}
// Post processing after initial render
// Required for tabs within Vue components or router view
Vue.nextTick(() => {
for (let i = 0; i < postRenderQueue.length; i++) {
const vnode = postRenderQueue[i];
if (vnode && vnode.elm && vnode.elm.nodeName === 'ION-TAB') {
const ionTab = vnode.elm as HTMLIonTabElement;
const vnodeData = {
data: {
attrs: { tab: ionTab.getAttribute('tab'), routes: ionTab.getAttribute('route') },
},
};
const tabName = matchRouteToTab(vnodeData as any, route);
// Set tab active state
ionTab.active = !!tabName;
// Loop through all tab-bars and set active tab
if (tabName) {
for (const tabBar of tabBars) {
(tabBar.elm as HTMLIonTabBarElement).selectedTab = tabName;
}
}
cachedTabs[i] = vnode;
}
}
// Free tab-bars references
tabBars.length = 0;
});
// Render
return h('div', { ...data, style: hostStyles }, [
parseSlot(slots().top, selectedTab, listeners),
h('div', { class: 'tabs-inner', style: tabsInner }, renderQueue),
parseSlot(slots().bottom, selectedTab, listeners),
]);
}
};
// Search for ion-tab-bar in VNode array
function parseSlot(slot: VNode[], tab: string, listeners: EventListeners): VNode[] {
const vnodes = [] as VNode[];
if (!slot) {
return vnodes;
}
for (const vnode of slot) {
vnodes.push(vnode.tag && vnode.tag.match(/ion-tab-bar$/) ? parseTabBar(vnode, tab, listeners) : vnode);
}
return vnodes;
}
// Set selected tab attribute and click handlers
function parseTabBar(vnode: VNode, tab: string, listeners: EventListeners): VNode {
const { IonTabsWillChange, IonTabsDidChange } = listeners;
if (!vnode.data) {
vnode.data = {
attrs: {
'selected-tab': tab,
},
};
} else if (!vnode.data.attrs) {
vnode.data.attrs = { 'selected-tab': tab };
} else {
vnode.data.attrs['selected-tab'] = tab;
}
// Loop through ion-tab-buttons and assign click handlers
// If custom click handler was provided, do not override it
if (vnode.children) {
for (const child of vnode.children) {
if (child.tag && child.tag === 'ion-tab-button') {
const clickHandler = (e: Event) => {
const path = (child.elm as HTMLIonTabButtonElement).tab || '/';
const route = hasDataAttr(child, 'to') ? child.data!.attrs!.to : { path };
e.preventDefault();
if (Array.isArray(IonTabsWillChange)) {
IonTabsWillChange.map(item => item(route));
} else if (IonTabsWillChange) {
IonTabsWillChange(route);
}
vnode.context!.$router.push(route, () => {
if (Array.isArray(IonTabsDidChange)) {
IonTabsDidChange.map(item => item(route));
} else if (IonTabsDidChange) {
IonTabsDidChange(route);
}
});
};
if (!child.data || !child.data.on || !child.data.on.click) {
Object.assign(child.data, { on: { click: clickHandler } });
} else if (child.data.on.click) {
// Always push our click handler to end of array
if (Array.isArray(child.data.on.click)) {
child.data.on.click.push(clickHandler);
} else {
child.data.on.click = [child.data.on.click as Function, clickHandler];
}
}
}
}
}
// Store a reference to the matched ion-tab-bar
tabBars.push(vnode);
return vnode;
}
// Check if a VNode has a specific attribute set
function hasDataAttr(vnode: VNode, attr: string) {
return vnode.data && vnode.data.attrs && vnode.data.attrs[attr];
}
// Match tab to route through :routes property
// Otherwise match by URL
function matchRouteToTab(vnode: VNode, route: Route): string {
if (!vnode.data || !vnode.data.attrs || !vnode.data.attrs.tab) {
throw new Error('The tab attribute is required for an ion-tab element');
}
const tabName = vnode.data.attrs.tab;
// Handle route matching by :routes attribute
if (vnode.data.attrs.routes) {
const routes = Array.isArray(vnode.data.attrs.routes)
? vnode.data.attrs.routes
: vnode.data.attrs.routes.replace(' ', '').split(',');
// Parse an array of possible matches
for (const r of routes) {
if (route.name === r) {
return tabName;
}
}
} else {
if (route.path.indexOf(tabName) > -1) {
return tabName;
}
}
return '';
}
// CSS for ion-tabs inner and outer elements
const hostStyles = {
display: 'flex',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'column',
width: '100%',
height: '100%',
contain: 'layout size style',
};
const tabsInner = {
position: 'relative',
flex: 1,
contain: 'layout size style',
};

View File

@ -1,112 +0,0 @@
import { CreateElement } from 'vue';
export default {
functional: true,
props: {
name: {
type: String,
default: 'default'
}
},
render(_: CreateElement, { props, children, parent, data }: any) {
// used by devtools to display a router-view badge
data.routerView = true;
// directly use parent context's createElement() function
// so that components rendered by router-view can resolve named slots
const h = parent.$createElement;
const name = props.name;
const route = parent.$route;
const cache = parent._routerViewCache || (parent._routerViewCache = {});
// determine current view depth, also check to see if the tree
// has been toggled inactive but kept-alive.
let depth = 0;
let inactive = false;
while (parent && parent._routerRoot !== parent) {
if (parent.$vnode && parent.$vnode.data.routerView) {
depth++;
}
if (parent._inactive) {
inactive = true;
}
parent = parent.$parent;
}
data.routerViewDepth = depth;
// render previous view if the tree is inactive and kept-alive
if (inactive) {
return h(cache[name], data, children);
}
const matched = route.matched[depth];
// render empty node if no matched route
if (!matched) {
cache[name] = null;
return h();
}
const component = (cache[name] = matched.components[name]);
// attach instance registration hook
// this will be called in the instance's injected lifecycle hooks
data.registerRouteInstance = (vm: any, val: any) => {
// val could be undefined for unregistration
const current = matched.instances[name];
if ((val && current !== vm) || (!val && current === vm)) {
matched.instances[name] = val;
}
};
// also register instance in prepatch hook
// in case the same component instance is reused across different routes
(data.hook || (data.hook = {})).prepatch = (_: any, vnode: any) => {
matched.instances[name] = vnode.componentInstance;
};
// resolve props
let propsToPass = (data.props = resolveProps(
route,
matched.props && matched.props[name]
));
if (propsToPass) {
// clone to prevent mutation
propsToPass = data.props = extend({}, propsToPass);
// pass non-declared props as attrs
const attrs = (data.attrs = data.attrs || {});
for (const key in propsToPass) {
if (!component.props || !(key in component.props)) {
attrs[key] = propsToPass[key];
delete propsToPass[key];
}
}
}
return h(component, data, children);
}
};
export function extend(a: any, b: any) {
for (const key in b) {
a[key] = b[key];
}
return a;
}
function resolveProps(route: any, config: any) {
switch (typeof config) {
case 'undefined':
return;
case 'object':
return config;
case 'function':
return config(route);
case 'boolean':
return config ? route.params : undefined;
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(
`props in "${route.path}" is a ${typeof config}, ` +
`expecting an object, function or boolean.`
);
}
}
}

View File

@ -1,5 +1,5 @@
import { VueConstructor, default as Vue } from 'vue';
import { FrameworkDelegate, ViewLifecycle } from '@ionic/core';
import { VueConstructor } from 'vue';
import { FrameworkDelegate, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_WILL_UNLOAD } from '@ionic/core';
import { EsModule, HTMLVueElement, WebpackFunction } from '../interfaces';
@ -15,7 +15,6 @@ function createVueComponent(vue: VueConstructor, component: WebpackFunction | ob
export class VueDelegate implements FrameworkDelegate {
constructor(
public vue: VueConstructor,
public $root: Vue
) {}
// Attach the passed Vue component to DOM
@ -37,11 +36,11 @@ export class VueDelegate implements FrameworkDelegate {
componentInstance.$mount();
// Add any classes to the Vue component's root element
addClasses(componentInstance.$el, classes);
addClasses(componentInstance.$el as HTMLElement, classes);
// Append the Vue component to DOM
parentElement.appendChild(componentInstance.$el);
return componentInstance.$el;
return componentInstance.$el as HTMLElement;
});
}
@ -57,11 +56,11 @@ export class VueDelegate implements FrameworkDelegate {
}
const LIFECYCLES = [
ViewLifecycle.WillEnter,
ViewLifecycle.DidEnter,
ViewLifecycle.WillLeave,
ViewLifecycle.DidLeave,
ViewLifecycle.WillUnload
LIFECYCLE_WILL_ENTER,
LIFECYCLE_DID_ENTER,
LIFECYCLE_WILL_LEAVE,
LIFECYCLE_DID_LEAVE,
LIFECYCLE_WILL_UNLOAD
];
export function bindLifecycleEvents(instance: any, element: HTMLElement) {

View File

@ -6,3 +6,4 @@ export default {
};
export { Controllers } from './ionic';
export { default as IonicVueRouter } from './router';

View File

@ -11,7 +11,7 @@ import {
import { IonicConfig } from '@ionic/core';
import { appInitialize } from './app-initialize';
import { VueDelegate } from './controllers/vue-delegate';
import IonRouterOutlet from './components/router-outlet';
import IonTabs from './components/navigation/IonTabs';
export interface Controllers {
actionSheetController: ActionSheetController;
@ -30,10 +30,11 @@ declare module 'vue/types/vue' {
}
function createApi(Vue: VueConstructor, $root: VueImport) {
function createApi(Vue: VueConstructor) {
const cache: Partial<Controllers> = {};
const vueDelegate = new VueDelegate(Vue, $root);
const api: Controllers = {
const vueDelegate = new VueDelegate(Vue);
return {
get actionSheetController() {
if (!cache.actionSheetController) {
cache.actionSheetController = new ActionSheetController();
@ -77,8 +78,6 @@ function createApi(Vue: VueConstructor, $root: VueImport) {
return cache.toastController;
}
};
return api;
}
let Vue: typeof VueImport;
@ -94,11 +93,13 @@ export const install: PluginFunction<IonicConfig> = (_Vue, config) => {
}
Vue = _Vue;
Vue.config.ignoredElements.push(/^ion-/);
Vue.component('IonRouterView', IonRouterOutlet);
Vue.component('IonTabs', IonTabs);
appInitialize(config);
const api = createApi(Vue);
Object.defineProperty(Vue.prototype, '$ionic', {
get() { return createApi(Vue, this.$root); }
get() { return api; }
});
};

View File

@ -3,6 +3,7 @@ import { PluginFunction } from 'vue';
import { RouterArgs, VueWindow } from './interfaces';
import IonVueRouter from './components/ion-vue-router.vue';
import IonVueRouterTransitionless from './components/ion-vue-router-transitionless.vue';
import { BackButtonEvent } from '@ionic/core';
const vueWindow = window as VueWindow;
const inBrowser: boolean = typeof window !== 'undefined';
@ -37,6 +38,13 @@ export default class Router extends _VueRouter {
// Extend the existing history object
this.extendHistory();
// Listen to Ionic's back button event
document.addEventListener('ionBackButton', (e: Event) => {
(e as BackButtonEvent).detail.register(0, () => {
this.back();
});
});
}
extendHistory(): void {