22 <nav
33 class =" nav"
44 :class =" { 'nav--sticky': !navIsAtTop || isMobileNavOpen }"
5- : aria-label =" isMobileNavOpen ? ' Main navigation (expanded)' : 'Main navigation' "
5+ aria-label =" Main navigation"
66 >
77 <div class =" nav__wrapper" >
88 <nuxt-link v-once to =" /" class =" nav__name" >
99 Jack Domleo
1010 <span class =" sr-only" > - Home</span >
1111 </nuxt-link >
12- <nuxt-link v-once to = " / " class = " nav__logo " >
12+ <span v-once class = " nav__logo " aria-hidden = " true " >
1313 <Icon v-once name =" custom:j-icon" mode =" svg" />
14- <span class =" sr-only" >Home</span >
15- </nuxt-link >
14+ </span >
1615 <div v-if =" !isMobile" class =" nav__primary" >
1716 <ul class =" nav__primary-list" >
1817 <li v-for =" navItem in navItems" :key =" navItem.text" class =" nav__item" >
2322 v-else
2423 :aria-expanded =" expandedSubmenus.has(navItem.text)"
2524 :aria-controls =" getSubmenuId(navItem.text)"
26- aria-haspopup =" true"
2725 @click =" toggleSubmenu(navItem.text)"
2826 @keydown.esc =" closeAllSubmenus"
2927 >
3432 v-if =" navItem.submenu"
3533 :id =" getSubmenuId(navItem.text)"
3634 :aria-hidden =" !expandedSubmenus.has(navItem.text)"
37- role =" menu"
3835 >
39- <li v-for =" subItem in navItem.submenu" :key =" subItem.text" role = " none " >
36+ <li v-for =" subItem in navItem.submenu" :key =" subItem.text" >
4037 <nuxt-link
4138 v-if =" subItem.url"
4239 :to =" subItem.url"
43- role =" menuitem"
4440 @click =" closeAllSubmenus"
4541 >
4642 {{ subItem.text }}
7470 class =" nav__more"
7571 :class =" {'nav__more--open': isMobile && isMobileNavOpen}"
7672 :aria-hidden =" !isMobileNavOpen"
77- role =" menu"
7873 >
79- <li v-for =" navItem in mobileNavItems" :key =" navItem.text" role = " none " >
74+ <li v-for =" navItem in mobileNavItems" :key =" navItem.text" >
8075 <nuxt-link
8176 :to =" navItem.url!"
82- role =" menuitem"
8377 @click =" toggleMobileNav(false)"
8478 >
8579 {{ navItem.text }}
@@ -107,7 +101,7 @@ interface INav {
107101const isMobile = ref (false )
108102const isTouchscreen = ref (false )
109103const isMobileNavOpen = ref (false )
110- const navIsAtTop = ref (false )
104+ const navIsAtTop = ref (true )
111105const expandedSubmenus = ref (new Set <string >())
112106
113107const navItems: FixedLengthArray <INav , 3 > = [ // No more than 3 top-level items
@@ -155,39 +149,33 @@ function getSubmenuId(navItemText: string): string {
155149 return submenuIds .value .get (navItemText ) || ` submenu-${navItemText .toLowerCase ()} `
156150}
157151
158- // Focus trap functionality - memoized selectors
152+ // Focus trap functionality
159153let focusableElements: HTMLElement [] = []
160154let firstFocusableElement: HTMLElement | null = null
161155let lastFocusableElement: HTMLElement | null = null
162156
163- const FOCUSABLE_SELECTOR = ' a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]) '
164-
165- // Optimized focus trap with early returns and cached queries
157+ // Build the focusable list from known, named elements rather than filtering by
158+ // getBoundingClientRect(). The nav slides in via CSS transform, so dimensions are
159+ // unreliable mid-animation — elements appear off-screen until the transition settles.
166160function setupFocusTrap() {
167161 if (! isMobileNavOpen .value ) return
168-
169- const nav = document .querySelector (' nav.nav' ) as HTMLElement
170- if (! nav ) return
171-
172- const elements = nav .querySelectorAll (FOCUSABLE_SELECTOR )
173- focusableElements = Array .from (elements ).filter (el => {
174- const element = el as HTMLElement
175- const rect = element .getBoundingClientRect ()
176- return rect .width > 0 && rect .height > 0 && window .getComputedStyle (element ).visibility !== ' hidden'
177- }) as HTMLElement []
178-
179- if (focusableElements .length === 0 ) return
180-
162+
163+ const homeName = document .querySelector <HTMLElement >(' .nav__name' )
164+ const hamburger = document .querySelector <HTMLElement >(' .nav__hamburger' )
165+ const mobileNavLinks = Array .from (
166+ document .querySelectorAll <HTMLElement >(' #mobile-nav a[href]' )
167+ )
168+
169+ focusableElements = [homeName , hamburger , ... mobileNavLinks ].filter (
170+ (el ): el is HTMLElement => el !== null
171+ )
172+
181173 firstFocusableElement = focusableElements [0 ] || null
182174 lastFocusableElement = focusableElements [focusableElements .length - 1 ] || null
183-
184- // Focus the first non-hamburger element when opening
185- const hamburger = document .querySelector (' .nav__hamburger' ) as HTMLElement
186- const targetElement = firstFocusableElement !== hamburger ? firstFocusableElement : focusableElements [1 ]
187-
188- if (targetElement ) {
189- // Use requestAnimationFrame for smoother focus
190- requestAnimationFrame (() => targetElement .focus ())
175+
176+ // Move focus to the first nav link so the user lands directly in the list
177+ if (mobileNavLinks [0 ]) {
178+ mobileNavLinks [0 ].focus ()
191179 }
192180}
193181
@@ -254,6 +242,8 @@ watch(route, () => {
254242
255243watch (isMobileNavOpen , (newValue ) => {
256244 if (newValue ) {
245+ // nextTick ensures Vue has flushed DOM updates (--open class applied)
246+ // before we query #mobile-nav links.
257247 nextTick (() => setupFocusTrap ())
258248 }
259249})
@@ -275,9 +265,9 @@ onMounted(() => {
275265})
276266
277267onUnmounted (() => {
278- // Clear timeouts to prevent memory leaks
268+ // Clear pending timers/frames to prevent memory leaks
279269 if (resizeTimeout ) clearTimeout (resizeTimeout )
280- if (scrollTimeout ) clearTimeout ( scrollTimeout )
270+ if (scrollRafId ) cancelAnimationFrame ( scrollRafId )
281271
282272 window .removeEventListener (' resize' , setResponsiveness )
283273 window .removeEventListener (' scroll' , handleScroll )
@@ -287,7 +277,7 @@ onUnmounted(() => {
287277
288278// Throttled event handlers for performance
289279let resizeTimeout: NodeJS .Timeout | null = null
290- let scrollTimeout : NodeJS . Timeout | null = null
280+ let scrollRafId : number | null = null
291281
292282function setResponsiveness (): void {
293283 if (resizeTimeout ) return
@@ -312,21 +302,21 @@ function setResponsiveness (): void {
312302}
313303
314304function handleScroll (): void {
315- if (scrollTimeout ) return
316-
317- scrollTimeout = setTimeout (() => {
305+ if (scrollRafId ) return
306+
307+ scrollRafId = requestAnimationFrame (() => {
318308 navIsAtTop .value = ! (document .body .scrollTop > 10 || document .documentElement .scrollTop > 10 )
319- scrollTimeout = null
320- }, 16 ) // ~60fps
309+ scrollRafId = null
310+ })
321311}
322312
323313function toggleMobileNav (open : boolean ): void {
324314 isMobileNavOpen .value = open
325315 document .body .style .overflow = isMobileNavOpen .value ? ' hidden' : ' '
326316
327317 if (open ) {
328- nextTick (() => setupFocusTrap ())
329- }
318+ nextTick (() => setupFocusTrap ())
319+ }
330320}
331321 </script >
332322
@@ -403,7 +393,6 @@ $navHeight: 4rem;
403393 @media (prefers-reduced-motion : no- preference) {
404394 transition-property : top , transform , width ;
405395 transition : 360ms ease ;
406- will-change : top , transform , width ;
407396 }
408397 }
409398
@@ -468,7 +457,6 @@ $navHeight: 4rem;
468457 border-bottom : 1px solid transparent ;
469458
470459 @media (prefers-reduced-motion : no- preference) {
471- will-change : color , border-color ;
472460 transition-property : color , border-color ;
473461 transition : 160ms ease ;
474462 }
@@ -620,9 +608,10 @@ $navHeight: 4rem;
620608 font-size : var (--text-large );
621609
622610 @media (prefers-reduced-motion : no- preference) {
623- transition-property : height , padding , border ;
624- transition : 260ms ease ;
625- will-change : height , padding , border ;
611+ // Use a single longhand list rather than transition-property + transition shorthand.
612+ // The shorthand resets transition-property to 'all', which would animate visibility
613+ // and transform — causing the focus trap's element queries to run mid-animation.
614+ transition : transform 260ms ease , padding 260ms ease , border-top-width 260ms ease ;
626615 }
627616
628617 @media (forced-colors : active ) {
0 commit comments