forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>