Skip to content
Permalink
Browse files

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
  • Loading branch information...
michaeltintiuc authored and mhartington committed Mar 22, 2019
1 parent 439b10e commit ee7167512f20ea431a509073fc723ac76f6ef974
@@ -206,29 +206,55 @@ export default Example;

```html
<template>
<ion-tabs>
<ion-tab-bar slot="bottom">
<ion-tab-button tab="schedule">
<ion-icon name="calendar"></ion-icon>
<ion-label>Schedule</ion-label>
<ion-badge>6</ion-badge>
</ion-tab-button>

<ion-tab-button tab="speakers">
<ion-icon name="contacts"></ion-icon>
<ion-label>Speakers</ion-label>
</ion-tab-button>

<ion-tab-button tab="map">
<ion-icon name="map"></ion-icon>
<ion-label>Map</ion-label>
</ion-tab-button>

<ion-tab-button tab="about">
<ion-icon name="information-circle"></ion-icon>
<ion-label>About</ion-label>
</ion-tab-button>
</ion-tab-bar>
<!-- Listen to before and after tab change events -->
<ion-tabs @IonTabsWillChange="beforeTabChange" @IonTabsDidChange="afterTabChange">
<ion-tab tab="schedule">
<Schedule />
</ion-tab>

<!-- Match by "app.speakers" route name -->
<ion-tab tab="speakers" :routes="'app.speakers'">
<Speakers />
</ion-tab>

<!-- Match by an array of route names -->
<ion-tab tab="map" :routes="['app.map', 'app.other.route']">
<Map />
</ion-tab>

<!-- Get matched routes with a helper method -->
<ion-tab tab="about" :routes="getMatchedRoutes">
<About />
</ion-tab>

<!-- Use v-slot:bottom with Vue ^2.6.0 -->
<template slot="bottom">
<ion-tab-bar>
<ion-tab-button tab="schedule">
<ion-icon name="calendar"></ion-icon>
<ion-label>Schedule</ion-label>
<ion-badge>6</ion-badge>
</ion-tab-button>

<!-- Provide a custom route to navigate to -->
<ion-tab-button tab="speakers" :to="{ name: 'app.speakers' }">
<ion-icon name="contacts"></ion-icon>
<ion-label>Speakers</ion-label>
</ion-tab-button>

<!-- Provide extra data to route -->
<ion-tab-button tab="map" :to="{ name: 'app.map', params: { mode: 'dark' } }">
<ion-icon name="map"></ion-icon>
<ion-label>Map</ion-label>
</ion-tab-button>

<!-- Provide custom click handler -->
<ion-tab-button tab="about" @click="goToAboutTab">
<ion-icon name="information-circle"></ion-icon>
<ion-label>About</ion-label>
</ion-tab-button>
</ion-tab-bar>
</template>
</ion-tabs>
</template>
```
@@ -1,28 +1,54 @@
```html
<template>
<ion-tabs>
<ion-tab-bar slot="bottom">
<ion-tab-button tab="schedule">
<ion-icon name="calendar"></ion-icon>
<ion-label>Schedule</ion-label>
<ion-badge>6</ion-badge>
</ion-tab-button>
<!-- Listen to before and after tab change events -->
<ion-tabs @IonTabsWillChange="beforeTabChange" @IonTabsDidChange="afterTabChange">
<ion-tab tab="schedule">
<Schedule />
</ion-tab>

<ion-tab-button tab="speakers">
<ion-icon name="contacts"></ion-icon>
<ion-label>Speakers</ion-label>
</ion-tab-button>
<!-- Match by "app.speakers" route name -->
<ion-tab tab="speakers" :routes="'app.speakers'">
<Speakers />
</ion-tab>

<ion-tab-button tab="map">
<ion-icon name="map"></ion-icon>
<ion-label>Map</ion-label>
</ion-tab-button>
<!-- Match by an array of route names -->
<ion-tab tab="map" :routes="['app.map', 'app.other.route']">
<Map />
</ion-tab>

<ion-tab-button tab="about">
<ion-icon name="information-circle"></ion-icon>
<ion-label>About</ion-label>
</ion-tab-button>
</ion-tab-bar>
<!-- Get matched routes with a helper method -->
<ion-tab tab="about" :routes="getMatchedRoutes">
<About />
</ion-tab>

<!-- Use v-slot:bottom with Vue ^2.6.0 -->
<template slot="bottom">
<ion-tab-bar>
<ion-tab-button tab="schedule">
<ion-icon name="calendar"></ion-icon>
<ion-label>Schedule</ion-label>
<ion-badge>6</ion-badge>
</ion-tab-button>

<!-- Provide a custom route to navigate to -->
<ion-tab-button tab="speakers" :to="{ name: 'app.speakers' }">
<ion-icon name="contacts"></ion-icon>
<ion-label>Speakers</ion-label>
</ion-tab-button>

<!-- Provide extra data to route -->
<ion-tab-button tab="map" :to="{ name: 'app.map', params: { mode: 'dark' } }">
<ion-icon name="map"></ion-icon>
<ion-label>Map</ion-label>
</ion-tab-button>

<!-- Provide custom click handler -->
<ion-tab-button tab="about" @click="goToAboutTab">
<ion-icon name="information-circle"></ion-icon>
<ion-label>About</ion-label>
</ion-tab-button>
</ion-tab-bar>
</template>
</ion-tabs>
</template>
```
@@ -59,6 +59,6 @@
"vue-router": "^3.0.1"
},
"dependencies": {
"@ionic/core": "4.0.0-beta.12"
"@ionic/core": "^4.0.0"
}
}
@@ -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',
};

0 comments on commit ee71675

Please sign in to comment.
You can’t perform that action at this time.