Skip to content

Commit 7897ae8

Browse files
committed
feat(a11y): Navigation UX & performance improvements
1 parent d29c5c8 commit 7897ae8

File tree

1 file changed

+41
-52
lines changed

1 file changed

+41
-52
lines changed

app/components/Navigation.vue

Lines changed: 41 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@
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">
@@ -23,7 +22,6 @@
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
>
@@ -34,13 +32,11 @@
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 }}
@@ -74,12 +70,10 @@
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 {
107101
const isMobile = ref(false)
108102
const isTouchscreen = ref(false)
109103
const isMobileNavOpen = ref(false)
110-
const navIsAtTop = ref(false)
104+
const navIsAtTop = ref(true)
111105
const expandedSubmenus = ref(new Set<string>())
112106
113107
const 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
159153
let focusableElements: HTMLElement[] = []
160154
let firstFocusableElement: HTMLElement | null = null
161155
let 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.
166160
function 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
255243
watch(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
277267
onUnmounted(() => {
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
289279
let resizeTimeout: NodeJS.Timeout | null = null
290-
let scrollTimeout: NodeJS.Timeout | null = null
280+
let scrollRafId: number | null = null
291281
292282
function setResponsiveness (): void {
293283
if (resizeTimeout) return
@@ -312,21 +302,21 @@ function setResponsiveness (): void {
312302
}
313303
314304
function 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
323313
function 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

Comments
 (0)