Search code examples
vue-componentvuejs3sidebarheadless-ui

How to set active state on a menu tree with 2 child arrays vue 3?


I have a sidebar with a NavBarItem component that receives a navItem object as a prop, and I want to change the active state to true when I click the link.

NavBar.vue

<template>
      <nav class="mt-2 px-2">
        <NavBarItem
          @click="setActiveLink(item, _index)"
          :item="item"
          v-for="(item, _index) in navItems"
          :key="item.label"
        />
      </nav>
</template>

<script setup>
  const setActiveLink = (el, i) => {
    // 🤬🤯
  }

  const navItems = [
    {
      href: '#',
      active: false,
      label: 'VehicleBuild',
      icon: vehicleTruckCube,
      children: [
        {
          href: '/vehicle-packages',
          active: false,
          label: 'Paket',
          icon: CubeIcon,
          children: [],
        },
      ],
    }]
</script>

NavBarItem.vue

<script setup>
  import { RouterLink } from 'vue-router'
  import { computed } from 'vue'
  import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
  import { ChevronDownIcon } from '@heroicons/vue/24/outline'
  const props = defineProps({
    item: Object,
  })

  const hasActiveChild = computed(() => {
    function hasActiveItem(items) {
      return items.some(item => item.active || hasActiveItem(item.children))
    }
    return hasActiveItem(props.item.children)
  })

  const emit = defineEmits(['click'])
</script>

<template>
  <RouterLink
    v-if="!item.children.length"
    :class="[
      'group flex w-full items-center rounded-md py-2 px-3 text-sm',
      'hover:bg-gray-100',
      item.active ? 'text-gray-800 font-semibold' : 'text-gray-600 font-medium',
    ]"
    :to="item.href"
    @click="emit('click', $event)"
  >
    <component
      :class="[
        'w-6 h-6 shrink-0 mr-2 group-hover:text-gray-600',
        item.active ? 'text-gray-600' : 'text-gray-400',
      ]"
      :is="item.icon"
      v-if="item.icon"
    ></component>
    <span>{{ item.label }}</span>
  </RouterLink>

  <Disclosure v-else v-slot="{ open }" :default-open="hasActiveChild">
    <DisclosureButton
      :class="[
        'group flex w-full text-left items-center rounded-md py-2 px-3 text-sm',
        'hover:bg-gray-100',
        open ? 'font-semibold text-gray-800' : 'font-medium text-gray-600',
      ]"
    >
      <component
        :class="[
          'w-6 h-6 shrink-0 mr-2 group-hover:text-gray-600',
          open ? 'text-gray-600' : 'text-gray-400',
        ]"
        :is="item.icon"
        v-if="item.icon"
      ></component>
      <span class="flex-1">{{ item.label }}</span>
      <ChevronDownIcon
        :class="['w-4 h-4 shrink-0', open ? '-rotate-180 text-gray-600' : 'text-gray-400']"
      />
    </DisclosureButton>
    <DisclosurePanel class="ml-4">
      <NavBarItem
        v-for="(child, index) in item.children"
        :item="child"
        :key="index"
        @click="emit('click', $event)"
      />
    </DisclosurePanel>
  </Disclosure>
</template>

I have tried to map the item I receive back from my emit in navBarItem but with no success, can somebody please help me out?


Solution

  • Any mutation you perform to navItems after mounting <Navbar /> will not trigger an update in <template /> because the array is not reactive.

    To add reactivity to it, wrap it in ref():

    const navItems = ref([ /* items here */ ])
    

    In your setActiveLink method update navItems.value accordingly and you will see the template updating accordingly.

    Docs here.


    Important note: Storing the active property inside each item only makes sense when you want to handle each item's active state individually (e.g: you want to be able to have more than 1 active item at a time). If the active state is mutually exclusive (e.g: making an item active means the previous one is no longer active), you should not store the active state inside each item.

    Instead, you set a state variable holding the id of the currently active item. And the isActive property of each item is a computed which compares the item's id with the value of the state variable. Evidently, when setting the state var to a different id the old active item becomes inactive, since its id stops being equal to the state variable's value.

    Proof of concept:

    const { createApp, reactive, toRefs } = Vue
    createApp({
      setup() {
        const state = reactive({
          menuItems: ["A", "B", "C", "D", "E"].map((id) => ({ id })),
          activeItem: "B",
          isActive: (id) => state.activeItem === id,
          setActive: (id) => (state.activeItem = id)
        })
        return toRefs(state)
      }
    }).mount("#app")
    #app span {
      border-bottom: 2px solid transparent;
      padding: 1rem;
      cursor: pointer;
    }
    #app span:hover {
      border-bottom: 2px solid #eee;
    }
    #app span.active {
      border-bottom: 2px solid red;
    }
    <script src="https://unpkg.com/vue@3.2.47/dist/vue.global.prod.js"></script>
    <div id="app">
      <span
        v-for="({ id }, key) in menuItems"
        :key="key"
        v-text="id"
        @click="() => setActive(id)"
        :class="{active: isActive(id)}"
      ></span>
      <pre
        v-text="JSON.stringify({
        activeItem,
        menuItems: menuItems.map(({id}) => ({id, active: isActive(id)}))
      }, null, 2)"
      ></pre>
    </div>