1<template> 2 <div> 3 <div class="nav-container" :class="{ open: isNavigationOpen }"> 4 <nav ref="nav" :aria-label="$t('appNavigation.primaryNavigation')"> 5 <b-nav vertical class="mb-4"> 6 <template v-for="navItem in navigationItems"> 7 <!-- Navigation items with no children --> 8 <li 9 v-if="!navItem.children" 10 :key="`nav-${navItem.index}`" 11 class="nav-item" 12 > 13 <router-link 14 :to="navItem.route" 15 :data-test-id="`nav-item-${navItem.id}`" 16 class="nav-link" 17 > 18 <component :is="navItem.icon" /> 19 {{ navItem.label }} 20 </router-link> 21 </li> 22 23 <!-- Navigation items with children --> 24 <li v-else :key="`nav-group-${navItem.index}`" class="nav-item"> 25 <b-button 26 :class="{ collapsed: !isItemOpen(navItem.id) }" 27 variant="link" 28 :data-test-id="`nav-button-${navItem.id}`" 29 :aria-controls="navItem.id" 30 :aria-expanded="isItemOpen(navItem.id) ? 'true' : 'false'" 31 @click="toggleCollapse(navItem.id)" 32 > 33 <component :is="navItem.icon" /> 34 {{ navItem.label }} 35 <icon-expand class="icon-expand" /> 36 </b-button> 37 <b-collapse 38 :id="navItem.id" 39 v-model="openSections[navItem.id]" 40 tag="ul" 41 class="nav-item__nav" 42 > 43 <li 44 v-for="(subNavItem, i) in filteredNavItem(navItem.children)" 45 :key="i" 46 class="nav-item" 47 > 48 <router-link 49 :to="subNavItem.route" 50 :data-test-id="`nav-item-${subNavItem.id}`" 51 class="nav-link" 52 > 53 {{ subNavItem.label }} 54 </router-link> 55 </li> 56 </b-collapse> 57 </li> 58 </template> 59 </b-nav> 60 </nav> 61 </div> 62 <transition name="fade"> 63 <div 64 v-if="isNavigationOpen" 65 id="nav-overlay" 66 class="nav-overlay" 67 @click="toggleIsOpen" 68 ></div> 69 </transition> 70 </div> 71</template> 72 73<script> 74//Do not change Mixin import. 75//Exact match alias set to support 76//dotenv customizations. 77import AppNavigationMixin from './AppNavigationMixin'; 78import { useI18n } from 'vue-i18n'; 79 80export default { 81 name: 'AppNavigation', 82 mixins: [AppNavigationMixin], 83 data() { 84 return { 85 $t: useI18n().t, 86 isNavigationOpen: false, 87 currentUserRole: null, 88 openSections: {}, 89 }; 90 }, 91 watch: { 92 $route: function () { 93 this.isNavigationOpen = false; 94 // Ensure the parent section of the current route is expanded 95 this.initializeOpenSectionsFromRoute(); 96 }, 97 isNavigationOpen: function (isNavigationOpen) { 98 require('@/eventBus').default.$emit( 99 'change-is-navigation-open', 100 isNavigationOpen, 101 ); 102 }, 103 }, 104 mounted() { 105 this.getPrivilege(); 106 require('@/eventBus').default.$on('toggle-navigation', () => 107 this.toggleIsOpen(), 108 ); 109 // Expand the parent section for the current route on initial load/refresh 110 this.initializeOpenSectionsFromRoute(); 111 }, 112 beforeUnmount() { 113 require('@/eventBus').default.$off( 114 'toggle-navigation', 115 this.handleToggleNavigation, 116 ); 117 }, 118 methods: { 119 isItemOpen(id) { 120 return !!this.openSections[id]; 121 }, 122 toggleCollapse(id) { 123 if (this.$set) { 124 this.$set(this.openSections, id, !this.openSections[id]); 125 } else { 126 this.openSections = { 127 ...this.openSections, 128 [id]: !this.openSections[id], 129 }; 130 } 131 }, 132 toggleIsOpen() { 133 this.isNavigationOpen = !this.isNavigationOpen; 134 }, 135 getPrivilege() { 136 this.currentUserRole = this.$store?.getters['global/userPrivilege']; 137 }, 138 filteredNavItem(navItem) { 139 if (this.currentUserRole) { 140 return navItem.filter(({ exclusiveToRoles }) => { 141 if (!exclusiveToRoles?.length) return true; 142 return exclusiveToRoles.includes(this.currentUserRole); 143 }); 144 } else return navItem; 145 }, 146 initializeOpenSectionsFromRoute() { 147 const currentPath = this.$route?.path; 148 if (!currentPath) return; 149 const sectionsToOpen = {}; 150 for (const item of this.navigationItems) { 151 if ( 152 item.children && 153 item.children.some((child) => child.route === currentPath) 154 ) { 155 sectionsToOpen[item.id] = true; 156 } 157 } 158 this.openSections = { ...this.openSections, ...sectionsToOpen }; 159 }, 160 }, 161}; 162</script> 163 164<style scoped lang="scss"> 165svg { 166 fill: currentColor; 167 height: 1.2rem; 168 width: 1.2rem; 169 margin-inline-start: 0 !important; //!important overriding button specificity 170 vertical-align: text-bottom; 171 &:not(.icon-expand) { 172 margin-inline-end: $spacer; 173 } 174} 175 176.nav { 177 padding-top: calc(#{$spacer} / 4); 178 @include media-breakpoint-up($responsive-layout-bp) { 179 padding-top: $spacer; 180 } 181} 182 183.nav-item__nav { 184 list-style: none; 185 padding-inline-start: 0; 186 margin-inline-start: 0; 187 188 .nav-item { 189 outline: none; 190 list-style: none; 191 } 192 193 .nav-link { 194 padding-inline-start: $spacer * 4; 195 outline: none; 196 197 &:not(.nav-link--current) { 198 font-weight: normal; 199 } 200 } 201} 202 203.btn-link { 204 display: inline-block; 205 width: 100%; 206 text-align: start; 207 text-decoration: none !important; 208 border-radius: 0; 209 210 &.collapsed { 211 .icon-expand { 212 transform: rotate(180deg); 213 } 214 } 215} 216 217.icon-expand { 218 float: inline-end; 219 margin-top: calc(#{$spacer} / 4); 220} 221 222.btn-link, 223.nav-link { 224 position: relative; 225 font-weight: $headings-font-weight; 226 padding-inline-start: $spacer; // defining consistent padding for links and buttons 227 padding-inline-end: $spacer; 228 color: theme-color('secondary'); 229 230 &:hover { 231 background-color: theme-color-level(dark, -10.5); 232 color: theme-color('dark'); 233 } 234 235 &:focus { 236 background-color: theme-color-level(light, 0); 237 box-shadow: inset 0 0 0 2px theme-color('primary'); 238 color: theme-color('dark'); 239 outline: 0; 240 } 241 242 &:active { 243 background-color: theme-color('secondary'); 244 color: $white; 245 } 246} 247 248.nav-link--current { 249 font-weight: $headings-font-weight; 250 background-color: theme-color('secondary'); 251 color: theme-color('light'); 252 cursor: default; 253 box-shadow: none; 254 255 &::before { 256 content: ''; 257 position: absolute; 258 top: 0; 259 bottom: 0; 260 inset-inline-start: 0; 261 width: 4px; 262 background-color: theme-color('primary'); 263 } 264 265 &:hover, 266 &:focus { 267 background-color: theme-color('secondary'); 268 color: theme-color('light'); 269 } 270} 271 272.nav-container { 273 position: fixed; 274 width: $navigation-width; 275 top: $header-height; 276 bottom: 0; 277 left: 0; 278 z-index: $zindex-fixed; 279 overflow-y: auto; 280 background-color: theme-color('light'); 281 transform: translateX(-$navigation-width); 282 transition: transform $exit-easing--productive $duration--moderate-02; 283 border-inline-end: 1px solid theme-color-level('light', 2.85); 284 285 @include media-breakpoint-down(md) { 286 z-index: $zindex-fixed + 2; 287 } 288 289 &.open, 290 &:focus-within { 291 transform: translateX(0); 292 transition-timing-function: $entrance-easing--productive; 293 } 294 295 @include media-breakpoint-up($responsive-layout-bp) { 296 transition-duration: $duration--fast-01; 297 transform: translateX(0); 298 } 299} 300 301.nav-overlay { 302 position: fixed; 303 top: $header-height; 304 bottom: 0; 305 left: 0; 306 right: 0; 307 z-index: $zindex-fixed + 1; 308 background-color: $black; 309 opacity: 0.5; 310 311 &.fade-enter-active { 312 transition: opacity $duration--moderate-02 $entrance-easing--productive; 313 } 314 315 &.fade-leave-active { 316 transition: opacity $duration--fast-02 $exit-easing--productive; 317 } 318 319 &.fade-enter, // Remove this vue2 based only class when switching to vue3 320 &.fade-enter-from, // This is vue3 based only class modified from 'fade-enter' 321 &.fade-leave-to { 322 opacity: 0; 323 } 324 325 @include media-breakpoint-up($responsive-layout-bp) { 326 display: none; 327 } 328} 329</style> 330