[READ-ONLY] a fast, modern browser for the npm registry
at main 308 lines 8.4 kB view raw
1<script setup lang="ts"> 2import { LinkBase } from '#components' 3import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' 4import { isEditableElement } from '~/utils/input' 5import { NPMX_DOCS_SITE } from '#shared/utils/constants' 6 7withDefaults( 8 defineProps<{ 9 showLogo?: boolean 10 }>(), 11 { 12 showLogo: true, 13 }, 14) 15 16const { isConnected, npmUser } = useConnector() 17 18const desktopLinks = computed<NavigationConfig>(() => [ 19 { 20 name: 'Compare', 21 label: $t('nav.compare'), 22 to: { name: 'compare' }, 23 keyshortcut: 'c', 24 type: 'link', 25 external: false, 26 iconClass: 'i-lucide:git-compare', 27 }, 28 { 29 name: 'Settings', 30 label: $t('nav.settings'), 31 to: { name: 'settings' }, 32 keyshortcut: ',', 33 type: 'link', 34 external: false, 35 iconClass: 'i-lucide:settings', 36 }, 37]) 38 39const mobileLinks = computed<NavigationConfigWithGroups>(() => [ 40 { 41 name: 'Desktop Links', 42 type: 'group', 43 items: [...desktopLinks.value], 44 }, 45 { 46 type: 'separator', 47 }, 48 { 49 name: 'About & Policies', 50 type: 'group', 51 items: [ 52 { 53 name: 'About', 54 label: $t('footer.about'), 55 to: { name: 'about' }, 56 type: 'link', 57 external: false, 58 iconClass: 'i-lucide:info', 59 }, 60 { 61 name: 'Privacy Policy', 62 label: $t('privacy_policy.title'), 63 to: { name: 'privacy' }, 64 type: 'link', 65 external: false, 66 iconClass: 'i-lucide:shield-check', 67 }, 68 { 69 name: 'Accessibility', 70 label: $t('a11y.title'), 71 to: { name: 'accessibility' }, 72 type: 'link', 73 external: false, 74 iconClass: 'i-custom:a11y', 75 }, 76 ], 77 }, 78 { 79 type: 'separator', 80 }, 81 { 82 name: 'External Links', 83 type: 'group', 84 label: $t('nav.links'), 85 items: [ 86 { 87 name: 'Docs', 88 label: $t('footer.docs'), 89 href: NPMX_DOCS_SITE, 90 target: '_blank', 91 type: 'link', 92 external: true, 93 iconClass: 'i-lucide:file-text', 94 }, 95 { 96 name: 'Source', 97 label: $t('footer.source'), 98 href: 'https://repo.npmx.dev', 99 target: '_blank', 100 type: 'link', 101 external: true, 102 iconClass: 'i-simple-icons:github', 103 }, 104 { 105 name: 'Social', 106 label: $t('footer.social'), 107 href: 'https://social.npmx.dev', 108 target: '_blank', 109 type: 'link', 110 external: true, 111 iconClass: 'i-simple-icons:bluesky', 112 }, 113 { 114 name: 'Chat', 115 label: $t('footer.chat'), 116 href: 'https://chat.npmx.dev', 117 target: '_blank', 118 type: 'link', 119 external: true, 120 iconClass: 'i-lucide:message-circle', 121 }, 122 ], 123 }, 124]) 125 126const showFullSearch = shallowRef(false) 127const showMobileMenu = shallowRef(false) 128const { env } = useAppConfig().buildInfo 129 130// On mobile, clicking logo+search button expands search 131const route = useRoute() 132const isMobile = useIsMobile() 133const isSearchExpandedManually = shallowRef(false) 134const searchBoxRef = useTemplateRef('searchBoxRef') 135 136// On search page, always show search expanded on mobile 137const isOnHomePage = computed(() => route.name === 'index') 138const isOnSearchPage = computed(() => route.name === 'search') 139const isSearchExpanded = computed(() => isOnSearchPage.value || isSearchExpandedManually.value) 140 141function expandMobileSearch() { 142 isSearchExpandedManually.value = true 143 nextTick(() => { 144 searchBoxRef.value?.focus() 145 }) 146} 147 148watch( 149 isOnSearchPage, 150 visible => { 151 if (!visible) return 152 153 searchBoxRef.value?.focus() 154 nextTick(() => { 155 searchBoxRef.value?.focus() 156 }) 157 }, 158 { flush: 'sync' }, 159) 160 161function handleSearchBlur() { 162 showFullSearch.value = false 163 // Collapse expanded search on mobile after blur (with delay for click handling) 164 // But don't collapse if we're on the search page 165 if (isMobile.value && !isOnSearchPage.value) { 166 setTimeout(() => { 167 isSearchExpandedManually.value = false 168 }, 150) 169 } 170} 171 172function handleSearchFocus() { 173 showFullSearch.value = true 174} 175 176onKeyStroke( 177 e => { 178 if (isEditableElement(e.target)) { 179 return 180 } 181 182 for (const link of desktopLinks.value) { 183 if (link.to && link.keyshortcut && isKeyWithoutModifiers(e, link.keyshortcut)) { 184 e.preventDefault() 185 navigateTo(link.to) 186 break 187 } 188 } 189 }, 190 { dedupe: true }, 191) 192</script> 193 194<template> 195 <header class="sticky top-0 z-50 border-b border-border"> 196 <div class="absolute inset-0 bg-bg/80 backdrop-blur-md" /> 197 <nav 198 :aria-label="$t('nav.main_navigation')" 199 class="relative container min-h-14 flex items-center gap-2 z-1 justify-end" 200 > 201 <!-- Mobile: Logo (navigates home) --> 202 <NuxtLink 203 v-if="!isSearchExpanded && !isOnHomePage" 204 to="/" 205 :aria-label="$t('header.home')" 206 class="sm:hidden flex-shrink-0 font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring" 207 > 208 <AppLogo class="w-8 h-8 rounded-lg" /> 209 </NuxtLink> 210 211 <!-- Desktop: Logo (navigates home) --> 212 <div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center"> 213 <NuxtLink 214 :to="{ name: 'index' }" 215 :aria-label="$t('header.home')" 216 dir="ltr" 217 class="relative inline-flex items-center gap-1 header-logo font-mono text-lg font-medium text-fg hover:text-fg/90 transition-colors duration-200 rounded" 218 > 219 <AppLogo class="w-7 h-7 rounded-lg" /> 220 <span class="pb-0.5">npmx</span> 221 <span 222 aria-hidden="true" 223 class="scale-35 transform-origin-br font-mono tracking-wide text-accent absolute bottom-0.5 -inset-ie-1" 224 > 225 {{ env === 'release' ? 'alpha' : env }} 226 </span> 227 </NuxtLink> 228 </div> 229 <!-- Spacer when logo is hidden on desktop --> 230 <span v-else class="hidden sm:block w-1" /> 231 232 <!-- Center: Search bar + nav items --> 233 <div 234 class="flex-1 flex items-center md:gap-6" 235 :class="{ 236 'hidden sm:flex': !isSearchExpanded, 237 'justify-end': isOnHomePage, 238 'justify-center': !isOnHomePage, 239 }" 240 > 241 <!-- Search bar (hidden on mobile unless expanded) --> 242 <HeaderSearchBox 243 ref="searchBoxRef" 244 :inputClass="isSearchExpanded ? 'w-full' : ''" 245 :class="{ 'max-w-md': !isSearchExpanded }" 246 @focus="handleSearchFocus" 247 @blur="handleSearchBlur" 248 /> 249 <ul 250 v-if="!isSearchExpanded && isConnected && npmUser" 251 :class="{ hidden: showFullSearch }" 252 class="hidden sm:flex items-center gap-4 sm:gap-6 list-none m-0 p-0" 253 > 254 <!-- Packages dropdown (when connected) --> 255 <li v-if="isConnected && npmUser" class="flex items-center"> 256 <HeaderPackagesDropdown :username="npmUser" /> 257 </li> 258 259 <!-- Orgs dropdown (when connected) --> 260 <li v-if="isConnected && npmUser" class="flex items-center"> 261 <HeaderOrgsDropdown :username="npmUser" /> 262 </li> 263 </ul> 264 </div> 265 266 <!-- End: Desktop nav items + Mobile menu button --> 267 <div class="hidden sm:flex flex-shrink-0"> 268 <!-- Desktop: Explore link --> 269 <LinkBase 270 v-for="link in desktopLinks" 271 :key="link.name" 272 class="border-none" 273 variant="button-secondary" 274 :to="link.to" 275 :aria-keyshortcuts="link.keyshortcut" 276 > 277 {{ link.label }} 278 </LinkBase> 279 280 <HeaderAccountMenu /> 281 </div> 282 283 <!-- Mobile: Search button (expands search) --> 284 <ButtonBase 285 type="button" 286 class="sm:hidden ms-auto" 287 :aria-label="$t('nav.tap_to_search')" 288 :aria-expanded="showMobileMenu" 289 @click="expandMobileSearch" 290 v-if="!isSearchExpanded && !isOnHomePage" 291 classicon="i-lucide:search" 292 /> 293 294 <!-- Mobile: Menu button (always visible, click to open menu) --> 295 <ButtonBase 296 type="button" 297 class="sm:hidden" 298 :aria-label="$t('nav.open_menu')" 299 :aria-expanded="showMobileMenu" 300 @click="showMobileMenu = !showMobileMenu" 301 classicon="i-lucide:menu" 302 /> 303 </nav> 304 305 <!-- Mobile menu --> 306 <HeaderMobileMenu :links="mobileLinks" v-model:open="showMobileMenu" /> 307 </header> 308</template>