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