Problem with tabs and shared ion-menu

Hi,
I’m trying to set up a common ion-menu for every tab in a tab-based interface.

I’ve added the same menu to each tab using ion-split-pane but when I navigate between tabs, I get an error, Cannot read property 'classList' of undefined because my tab content is not wrapped in the ion-page tag.

If I wrap each tab component in ion-page, (nesting ion-split-pane inside ion-page, or replacing ion-split-pane with ion-page) the menu stops opening on some tabs, after navigating between them.

Here’s my current approach, any suggestions welcome. I’m just showing the templates and the routing, that seems the be relevant stuff:

Tabs.vue:

<template>
  <ion-page>
    <ion-tabs>
      <ion-router-outlet></ion-router-outlet>
        <ion-tab-bar slot="bottom">
          <ion-tab-button tab="cards" href="cards">
            <ion-icon :icon="currentRouteName === 'cards' ? documents : documentsOutline" />
            <ion-label>Cards</ion-label>
          </ion-tab-button>

          <ion-tab-button tab="photos" href="photos">
            <ion-icon :icon="currentRouteName === 'photos' ? images : imagesOutline" />
            <ion-label>Photos</ion-label>
          </ion-tab-button>

          <ion-tab-button tab="places" href="places">
            <ion-icon :icon="currentRouteName === 'places' ? compass : compassOutline" />
            <ion-label>Places</ion-label>
          </ion-tab-button>
        </ion-tab-bar>
    </ion-tabs>
  </ion-page>
</template>

Cards.vue:

<template>
  <ion-split-pane content-id="cards-main">

    <ion-menu side="start" menu-id="first" content-id="cards-main">
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Start Menu</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-list>
          <ion-item>Menu Item</ion-item>
          <ion-item>Menu Item</ion-item>
        </ion-list>
      </ion-content>
    </ion-menu>

    <ion-page id="cards-main">
      <ion-header>
        <ion-toolbar>
          <ion-buttons slot="start">
            <ion-menu-button></ion-menu-button>
          </ion-buttons>
          <ion-title>{{ currentProjectTitle }}</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content :fullscreen="true">
          <!-- content here -->
      </ion-content>
    </ion-page>

  </ion-split-pane>
</template>

etc.

Photos.vue:

<template>
  <ion-split-pane content-id="photos-main">

    <ion-menu side="start" menu-id="second" content-id="photos-main">
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Start Menu</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-list>
          <ion-item>Menu Item</ion-item>
          <ion-item>Menu Item</ion-item>
        </ion-list>
      </ion-content>
    </ion-menu>

    <ion-page id="photos-main">
      <ion-header>
        <ion-toolbar>
          <ion-buttons slot="start">
            <ion-menu-button></ion-menu-button>
          </ion-buttons>
          <ion-title>Photos</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content :fullscreen="true">
        <!-- content here -->
      </ion-content>
    </ion-page>
  </ion-split-pane>
</template>

Routing:

{
    path: '/project/:project_id/tabs',
    component: Tabs,
    children: [
      {
        path: '',
        redirect: '/project/:project_id/tabs/cards'
      },
      {
        path: 'cards',
        name: 'cards',
        component: () => import('@/views/Cards.vue'),
        beforeEnter: beforeEventLoadProjectData
      },
      {
        path: 'photos',
        name: 'photos',
        component: () => import('@/views/Photos.vue')
      },
      {
        path: 'places',
        name: 'places',
        component: () => import('@/views/Places.vue'),
        beforeEnter: beforeEventLoadProjectData
      }
    ],
    meta: {
      requiresAuth: true,
      authoriseRoles: []
    }
  }

Well it seems to work if I wrap each Tab component in ion-page, set different menu-id and content-id for each Tab, and use menuController to explicitly open and close each menu.

<template>
  <ion-page content-id="places-main">

    <ion-menu side="start" menu-id="places-menu" content-id="places-main">
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Start Menu</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-list>
          <ion-item>Select Project</ion-item>
          <ion-item>Settings</ion-item>
        </ion-list>
      </ion-content>
    </ion-menu>

    <ion-page id="places-main">
      <ion-header>
        <ion-toolbar>
          <ion-buttons slot="start">
            <ion-menu-button menu="places-menu" auto-hide="false" @click="openMenu"></ion-menu-button>
          </ion-buttons>
          <ion-title>Places</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content :fullscreen="true">
      </ion-content>
    </ion-page>
  </ion-page>
</template>

Then in script section:

methods: {
      openMenu() {
        menuController.enable(true, 'places-menu');
        menuController.open('places-menu');
      }
}

Why are you putting the menus in the tabs??? You can leave the menu where it belongs and just have a separate component that is rendering inside the menu based on the visible tab… this might work, but I would question the maintainability of the code you are creating…

The whole thing with react, vue and angular is about components… step back and take another look with a component mindset.

Yes, I’m aware of the component based approach :slight_smile:

I started out by trying to add a single menu instance to the parent Tabs component. However I had problems getting this to work. I want the same toolbar icon in each tab to open the menu. The relationship between ion-menu and ion-menu-button is not wholly clear from the documentation.

In addition the issue I mentioned with routing which requires the use of ion-page to wrap tab content is not covered in the ion-menu documentation and examples. So any progress I’ve made at this point is based on just trying to get the ionic components working, and other examples I’ve seen on the web embed the menu in each tab.

So I can replace the menu section in each tab template with the same imported component which has an ion-menu in it – are you suggesting there is another fundamentally different approach which would be better? I’m open to suggestions!

Update: I managed to get it working with a single menu in the parent of the tabs, Tabs.vue, and ion-menu-button components in each tab, referencing the id of the menu.

However to get it to work I had to wrap the Tabs.vue content in ion-page, contrary to the ion-menu docs which say the ion-menu component should be at root level. Instead I have:

<template>
  <ion-page>
    <ion-menu side="start" menu-id="tabs-menu" content-id="tabs-main">
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Settings</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-list>
          <ion-item @click="navigateAwayTo('/projects')">Select Project</ion-item>
          <ion-item>Configuration</ion-item>
        </ion-list>
      </ion-content>
    </ion-menu>

    <ion-tabs>
      <ion-router-outlet></ion-router-outlet>
        <ion-tab-bar slot="bottom">
          <ion-tab-button tab="cards" href="cards">
            <ion-icon :icon="currentRouteName === 'cards' ? documents : documentsOutline" />
            <ion-label>Cards</ion-label>
          </ion-tab-button>

          <ion-tab-button tab="photos" href="photos">
            <ion-icon :icon="currentRouteName === 'photos' ? images : imagesOutline" />
            <ion-label>Photos</ion-label>
          </ion-tab-button>

          <ion-tab-button tab="places" href="places">
            <ion-icon :icon="currentRouteName === 'places' ? compass : compassOutline" />
            <ion-label>Places</ion-label>
          </ion-tab-button>
        </ion-tab-bar>
    </ion-tabs>
  </ion-page>
</template>

Example tab content:

<template>
  <ion-page id="tabs-main">
    <ion-header>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button menu="tabs-menu" auto-hide="false" @click="openMenu"></ion-menu-button>
        </ion-buttons>
        <ion-title>{{ currentProjectTitle }}</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content :fullscreen="true">
      <loading-message v-if="loading"></loading-message>
      <ion-list v-else>
        <ion-item
            v-for="(card, index) in cards"
            :key="index">
          <ion-label @click="openCard(card.id)">{{ card.title }}</ion-label>
        </ion-item>
      </ion-list>

      <ion-fab vertical="bottom" horizontal="end" slot="fixed">
        <ion-fab-button @click="addNewCard">
          <ion-icon :icon="add"></ion-icon>
        </ion-fab-button>
      </ion-fab>

    </ion-content>
  </ion-page>
</template>

Hope it might be helpful to someone.

I have one menu component that keeps track of the path and then renders the appropriate menu items based on the path that was selected.

the menu component is not in the tabs, it is at the top-level. see files below, might do a video or blog post explaining it in more detail over the weekend… code snippets below

<!-- app.vue -->
<template>
  <ion-app>
    <ion-split-pane content-id="main-content">
      <my-menu />
      <ion-router-outlet id="main-content"></ion-router-outlet>
    </ion-split-pane>
  </ion-app>
</template>

the menu

<template >
  <ion-menu
    content-id="main-content"
    type="overlay"
    @ionWillOpen="handleMenuWillOpen"
  >
    <ion-content>
      <ion-list id="inbox-list">
        <ion-list-header
          >Inbox : {{ this.$route.path }} {{ lastTabRoute }}</ion-list-header
        >
        <ion-menu-toggle
          auto-hide="false"
          v-for="(p, i) in currentAppPages"
          :key="i"
        >
          <ion-item
            @click="selectedIndex = i"
            router-direction="root"
            :router-link="p.url"
            lines="none"
            detail="false"
            class="hydrated"
            :class="{ selected: selectedIndex === i }"
          >
            <ion-icon slot="start" :ios="p.iosIcon" :md="p.mdIcon"></ion-icon>
            <ion-label>{{ p.title }}</ion-label>
          </ion-item>
        </ion-menu-toggle>
      </ion-list>
    </ion-content>
    <ion-footer style="text-align: center">
      <ion-button @click="doLogout">LOGOUT</ion-button>
    </ion-footer>
  </ion-menu>
</template>

<script lang="ts">
import {
  IonContent,
  IonIcon,
  IonItem,
  IonLabel,
  IonList,
  IonListHeader,
  IonMenu,
  IonMenuToggle,
  IonFooter,
  IonButton
} from "@ionic/vue";
import { defineComponent } from "vue";
import { useRoute } from "vue-router";
import {
  archiveOutline,
  archiveSharp,
  bookmarkOutline,
  bookmarkSharp,
  heartOutline,
  heartSharp,
  mailOutline,
  mailSharp,
  paperPlaneOutline,
  paperPlaneSharp,
  trashOutline,
  trashSharp,
  warningOutline,
  warningSharp
} from "ionicons/icons";

/**
 * the list of paths and titles for the menu items
 */
const appPages = {
  tab1: [
    {
      title: "Inbox",
      url: "/folder/Inbox",
      iosIcon: mailOutline,
      mdIcon: mailSharp
    },
    {
      title: "Outbox",
      url: "/folder/Outbox",
      iosIcon: paperPlaneOutline,
      mdIcon: paperPlaneSharp
    },
    {
      title: "Favorites",
      url: "/folder/Favorites",
      iosIcon: heartOutline,
      mdIcon: heartSharp
    }
  ],
  tab2: [
    {
      title: "Inbox-TAB2",
      url: "/folder/Inbox",
      iosIcon: mailOutline,
      mdIcon: mailSharp
    },
    {
      title: "Outbox",
      url: "/folder/Outbox",
      iosIcon: paperPlaneOutline,
      mdIcon: paperPlaneSharp
    }
  ],
  tab3: [
    {
      title: "Inbox-TAB3",
      url: "/folder/Inbox",
      iosIcon: mailOutline,
      mdIcon: mailSharp
    },
    {
      title: "Outbox",
      url: "/folder/Outbox",
      iosIcon: paperPlaneOutline,
      mdIcon: paperPlaneSharp
    },
    {
      title: "Issues",
      url: "/folder/Issues",
      iosIcon: paperPlaneOutline,
      mdIcon: paperPlaneSharp
    },
    {
      title: "Sales",
      url: "/folder/Sales",
      iosIcon: paperPlaneOutline,
      mdIcon: paperPlaneSharp
    }
  ]
};

export default defineComponent({
  components: {
    IonContent,
    IonIcon,
    IonItem,
    IonLabel,
    IonList,
    IonListHeader,
    IonMenu,
    IonMenuToggle,
    IonFooter,
    IonButton
  },
  computed: {
    /**
     * called to determine if the menu should be shown.
     *
     * we use the current route and look at the properties that are
     * set in the router; the route meta has a flag "hideMenu" to
     * indicate if the menu should be should be shown or not
     */
    showMenu() {
      const route = useRoute();
      return route.meta.hideMenu !== true;
    },
    /**
    * this code determines which items should be in menu based on the current
    * route
    */
    currentAppPages() {
      const key =
        this.$route.path.split("tabs/")[1] || (this.$data as any).lastTabRoute;

      if (key) {
        return (appPages as any)[key];
      } else {
        return [];
      }
    }
  },
  methods: {
    /**
     * connected to the menu ionWillOpen event.
     *
     * this will be called to check the path that is set in the route.
     * then loop through the predefined paths in the appPages property
     * to see if there is a match and if so then set that as the
     * selectedIndex
     *
     * the selectedIndex is used to set the styling so you can see
     * the highlighted menu item, and keep track of the last tab route
     */
    handleMenuWillOpen() {
      const route = useRoute();
   
      if (this.$route.path.split("tabs/")[1]) {
        this.lastTabRoute = this.$route.path.split("tabs/")[1];
      }

      const path = this.$route.path.split("folder/")[1];
      if (path !== undefined) {

        if (this.currentAppPages.length) {
          this.selectedIndex = this.currentAppPages.findIndex(
            (page: any) => page.title.toLowerCase() === path.toLowerCase()
          ) as any;
        } else {
          this.selectedIndex = (appPages as any)[this.lastTabRoute].findIndex(
            (page: any) => page.title.toLowerCase() === path.toLowerCase()
          ) as any;
        }
      }
    },
    async doLogout() {
      this.$router.replace("/login");
    }
  },
  data() {
    return {
      lastTabRoute: "",
      selectedIndex: 0,
      appPages,
      archiveOutline,
      archiveSharp,
      bookmarkOutline,
      bookmarkSharp,
      heartOutline,
      heartSharp,
      mailOutline,
      mailSharp,
      paperPlaneOutline,
      paperPlaneSharp,
      trashOutline,
      trashSharp,
      warningOutline,
      warningSharp
    };
  }
});
</script>

<style  scoped>
</style>

tab root

<template>
  <ion-page>
    <ion-tabs>
      <ion-router-outlet></ion-router-outlet>
      <ion-tab-bar slot="bottom">
        <ion-tab-button tab="tab1" href="/tabs/tab1">
          <ion-icon :icon="triangle" />
          <ion-label>Tab 1</ion-label>
        </ion-tab-button>
          
        <ion-tab-button tab="tab2" href="/tabs/tab2">
          <ion-icon :icon="ellipse" />
          <ion-label>Tab 2</ion-label>
        </ion-tab-button>
        
        <ion-tab-button tab="tab3" href="/tabs/tab3">
          <ion-icon :icon="square" />
          <ion-label>Tab 3</ion-label>
        </ion-tab-button>
      </ion-tab-bar>
    </ion-tabs>
  </ion-page>
</template>

all my tabs are basically the same for purpose of demo

<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button />
        </ion-buttons>
        <ion-title>Tab 2</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content :fullscreen="true">
      <ion-header collapse="condense">
        <ion-toolbar>
          <ion-title size="large">Tab 2</ion-title>
        </ion-toolbar>
      </ion-header>

      <ExploreContainer name="Tab 2 page" />
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import {
  IonPage,
  IonHeader,
  IonToolbar,
  IonTitle,
  IonContent,
  IonButtons,
  IonMenuButton
} from "@ionic/vue";
import ExploreContainer from "@/components/ExploreContainer.vue";

export default {
  name: "Tab2",
  components: {
    ExploreContainer,
    IonHeader,
    IonToolbar,
    IonTitle,
    IonContent,
    IonPage,
    IonButtons,
    IonMenuButton
  }
};
</script>
1 Like

Thanks for sharing this, I see what you’re doing. I’m just getting a handle on the quirks of the ionic components so its always useful to see examples.

i was also thinking that it can be simplified even more by doing this, set the menuKey directtly on the route so you dont need to do any parsing in the menu.

{
  path: 'tab3',
  component: () => import('@/views/Tab3.vue'),
  meta: {
    menuKey: "tab3"
  }
}

then get the key off the route…

currentAppPages() {
  const key = this.$route.meta.menuKey || (this.$data as any).lastTabRoute;

  if (key) {
    return (appPages as any)[key];
  } else {
    return [];
  }
}