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'; 78 79export default { 80 name: 'AppNavigation', 81 mixins: [AppNavigationMixin], 82 data() { 83 return { 84 isNavigationOpen: false, 85 currentUserRole: null, 86 openSections: {}, 87 }; 88 }, 89 watch: { 90 $route: function () { 91 this.isNavigationOpen = false; 92 // Ensure the parent section of the current route is expanded 93 this.initializeOpenSectionsFromRoute(); 94 }, 95 isNavigationOpen: function (isNavigationOpen) { 96 require('@/eventBus').default.$emit( 97 'change-is-navigation-open', 98 isNavigationOpen, 99 ); 100 }, 101 }, 102 mounted() { 103 this.getPrivilege(); 104 require('@/eventBus').default.$on('toggle-navigation', () => 105 this.toggleIsOpen(), 106 ); 107 // Expand the parent section for the current route on initial load/refresh 108 this.initializeOpenSectionsFromRoute(); 109 }, 110 beforeUnmount() { 111 require('@/eventBus').default.$off( 112 'toggle-navigation', 113 this.handleToggleNavigation, 114 ); 115 }, 116 methods: { 117 isItemOpen(id) { 118 return !!this.openSections[id]; 119 }, 120 toggleCollapse(id) { 121 if (this.$set) { 122 this.$set(this.openSections, id, !this.openSections[id]); 123 } else { 124 this.openSections = { 125 ...this.openSections, 126 [id]: !this.openSections[id], 127 }; 128 } 129 }, 130 toggleIsOpen() { 131 this.isNavigationOpen = !this.isNavigationOpen; 132 }, 133 getPrivilege() { 134 this.currentUserRole = this.$store?.getters['global/userPrivilege']; 135 }, 136 filteredNavItem(navItem) { 137 if (this.currentUserRole) { 138 return navItem.filter(({ exclusiveToRoles }) => { 139 if (!exclusiveToRoles?.length) return true; 140 return exclusiveToRoles.includes(this.currentUserRole); 141 }); 142 } else return navItem; 143 }, 144 initializeOpenSectionsFromRoute() { 145 const currentPath = this.$route?.path; 146 if (!currentPath) return; 147 const sectionsToOpen = {}; 148 for (const item of this.navigationItems) { 149 if ( 150 item.children && 151 item.children.some((child) => child.route === currentPath) 152 ) { 153 sectionsToOpen[item.id] = true; 154 } 155 } 156 this.openSections = { ...this.openSections, ...sectionsToOpen }; 157 }, 158 }, 159}; 160</script> 161 162<style scoped lang="scss"> 163svg { 164 fill: currentColor; 165 height: 1.2rem; 166 width: 1.2rem; 167 margin-inline-start: 0 !important; //!important overriding button specificity 168 vertical-align: text-bottom; 169 &:not(.icon-expand) { 170 margin-inline-end: $spacer; 171 } 172} 173 174.nav { 175 padding-top: calc(#{$spacer} / 4); 176 @include media-breakpoint-up($responsive-layout-bp) { 177 padding-top: $spacer; 178 } 179} 180 181.nav-item__nav { 182 list-style: none; 183 padding-inline-start: 0; 184 margin-inline-start: 0; 185 186 .nav-item { 187 outline: none; 188 list-style: none; 189 } 190 191 .nav-link { 192 padding-inline-start: $spacer * 4; 193 outline: none; 194 195 &:not(.nav-link--current) { 196 font-weight: normal; 197 } 198 } 199} 200 201.btn-link { 202 display: inline-block; 203 width: 100%; 204 text-align: start; 205 text-decoration: none !important; 206 border-radius: 0; 207 208 &.collapsed { 209 .icon-expand { 210 transform: rotate(180deg); 211 } 212 } 213} 214 215.icon-expand { 216 float: inline-end; 217 margin-top: calc(#{$spacer} / 4); 218} 219 220.btn-link, 221.nav-link { 222 position: relative; 223 font-weight: $headings-font-weight; 224 padding-inline-start: $spacer; // defining consistent padding for links and buttons 225 padding-inline-end: $spacer; 226 color: theme-color('secondary'); 227 228 &:hover { 229 background-color: theme-color-level(dark, -10.5); 230 color: theme-color('dark'); 231 } 232 233 &:focus { 234 background-color: theme-color-level(light, 0); 235 box-shadow: inset 0 0 0 2px theme-color('primary'); 236 color: theme-color('dark'); 237 outline: 0; 238 } 239 240 &:active { 241 background-color: theme-color('secondary'); 242 color: $white; 243 } 244} 245 246.nav-link--current { 247 font-weight: $headings-font-weight; 248 background-color: theme-color('secondary'); 249 color: theme-color('light'); 250 cursor: default; 251 box-shadow: none; 252 253 &::before { 254 content: ''; 255 position: absolute; 256 top: 0; 257 bottom: 0; 258 inset-inline-start: 0; 259 width: 4px; 260 background-color: theme-color('primary'); 261 } 262 263 &:hover, 264 &:focus { 265 background-color: theme-color('secondary'); 266 color: theme-color('light'); 267 } 268} 269 270.nav-container { 271 position: fixed; 272 width: $navigation-width; 273 top: $header-height; 274 bottom: 0; 275 left: 0; 276 z-index: $zindex-fixed; 277 overflow-y: auto; 278 background-color: theme-color('light'); 279 transform: translateX(-$navigation-width); 280 transition: transform $exit-easing--productive $duration--moderate-02; 281 border-inline-end: 1px solid theme-color-level('light', 2.85); 282 283 @include media-breakpoint-down(md) { 284 z-index: $zindex-fixed + 2; 285 } 286 287 &.open, 288 &:focus-within { 289 transform: translateX(0); 290 transition-timing-function: $entrance-easing--productive; 291 } 292 293 @include media-breakpoint-up($responsive-layout-bp) { 294 transition-duration: $duration--fast-01; 295 transform: translateX(0); 296 } 297} 298 299.nav-overlay { 300 position: fixed; 301 top: $header-height; 302 bottom: 0; 303 left: 0; 304 right: 0; 305 z-index: $zindex-fixed + 1; 306 background-color: $black; 307 opacity: 0.5; 308 309 &.fade-enter-active { 310 transition: opacity $duration--moderate-02 $entrance-easing--productive; 311 } 312 313 &.fade-leave-active { 314 transition: opacity $duration--fast-02 $exit-easing--productive; 315 } 316 317 &.fade-enter, // Remove this vue2 based only class when switching to vue3 318 &.fade-enter-from, // This is vue3 based only class modified from 'fade-enter' 319 &.fade-leave-to { 320 opacity: 0; 321 } 322 323 @include media-breakpoint-up($responsive-layout-bp) { 324 display: none; 325 } 326} 327</style> 328