[READ-ONLY] a fast, modern browser for the npm registry

feat: use navigation builder for desktop and mobile links (#1235)

authored by

bdbch and committed by
GitHub
98c68f5a ac7e6385

+188 -159
+118 -30
app/components/AppHeader.vue
··· 1 1 <script setup lang="ts"> 2 2 import { LinkBase } from '#components' 3 + import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' 3 4 import { isEditableElement } from '~/utils/input' 4 5 5 6 withDefaults( ··· 13 14 14 15 const { isConnected, npmUser } = useConnector() 15 16 17 + const 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 + 38 + const 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 + }, 69 + { 70 + type: 'separator', 71 + }, 72 + { 73 + name: 'External Links', 74 + type: 'group', 75 + label: $t('nav.links'), 76 + items: [ 77 + { 78 + name: 'Docs', 79 + label: $t('footer.docs'), 80 + href: 'https://docs.npmx.dev', 81 + target: '_blank', 82 + type: 'link', 83 + external: true, 84 + iconClass: 'i-carbon:document', 85 + }, 86 + { 87 + name: 'Source', 88 + label: $t('footer.source'), 89 + href: 'https://repo.npmx.dev', 90 + target: '_blank', 91 + type: 'link', 92 + external: true, 93 + iconClass: 'i-carbon:logo-github', 94 + }, 95 + { 96 + name: 'Social', 97 + label: $t('footer.social'), 98 + href: 'https://social.npmx.dev', 99 + target: '_blank', 100 + type: 'link', 101 + external: true, 102 + iconClass: 'i-simple-icons:bluesky', 103 + }, 104 + { 105 + name: 'Chat', 106 + label: $t('footer.chat'), 107 + href: 'https://chat.npmx.dev', 108 + target: '_blank', 109 + type: 'link', 110 + external: true, 111 + iconClass: 'i-carbon:chat', 112 + }, 113 + ], 114 + }, 115 + ]) 116 + 16 117 const showFullSearch = shallowRef(false) 17 118 const showMobileMenu = shallowRef(false) 18 119 ··· 63 164 } 64 165 65 166 onKeyStroke( 66 - e => isKeyWithoutModifiers(e, ',') && !isEditableElement(e.target), 67 167 e => { 68 - e.preventDefault() 69 - navigateTo({ name: 'settings' }) 70 - }, 71 - { dedupe: true }, 72 - ) 168 + if (isEditableElement(e.target)) { 169 + return 170 + } 73 171 74 - onKeyStroke( 75 - e => 76 - isKeyWithoutModifiers(e, 'c') && 77 - !isEditableElement(e.target) && 78 - // Allow more specific handlers to take precedence 79 - !e.defaultPrevented, 80 - e => { 81 - e.preventDefault() 82 - navigateTo({ name: 'compare' }) 172 + for (const link of desktopLinks.value) { 173 + if (link.to && link.keyshortcut && isKeyWithoutModifiers(e, link.keyshortcut)) { 174 + e.preventDefault() 175 + navigateTo(link.to.name) 176 + break 177 + } 178 + } 83 179 }, 84 180 { dedupe: true }, 85 181 ) ··· 156 252 157 253 <!-- End: Desktop nav items + Mobile menu button --> 158 254 <div class="hidden sm:flex flex-shrink-0"> 159 - <!-- Desktop: Compare link --> 160 - <LinkBase 161 - class="border-none" 162 - variant="button-secondary" 163 - :to="{ name: 'compare' }" 164 - keyshortcut="c" 165 - > 166 - {{ $t('nav.compare') }} 167 - </LinkBase> 168 - 169 - <!-- Desktop: Settings link --> 255 + <!-- Desktop: Explore link --> 170 256 <LinkBase 257 + v-for="link in desktopLinks" 258 + :key="link.name" 171 259 class="border-none" 172 260 variant="button-secondary" 173 - :to="{ name: 'settings' }" 174 - keyshortcut="," 261 + :to="link.to" 262 + :keyshortcut="link.keyshortcut" 175 263 > 176 - {{ $t('nav.settings') }} 264 + {{ link.label }} 177 265 </LinkBase> 178 266 179 267 <HeaderAccountMenu /> ··· 191 279 </nav> 192 280 193 281 <!-- Mobile menu --> 194 - <HeaderMobileMenu v-model:open="showMobileMenu" /> 282 + <HeaderMobileMenu :links="mobileLinks" v-model:open="showMobileMenu" /> 195 283 </header> 196 284 </template>
+41 -129
app/components/Header/MobileMenu.client.vue
··· 1 1 <script setup lang="ts"> 2 2 import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' 3 3 import { useAtproto } from '~/composables/atproto/useAtproto' 4 + import type { NavigationConfigWithGroups } from '~/types' 4 5 5 6 const isOpen = defineModel<boolean>('open', { default: false }) 7 + const { links } = defineProps<{ 8 + links: NavigationConfigWithGroups 9 + }>() 6 10 7 11 const { isConnected, npmUser, avatar: npmAvatar } = useConnector() 8 12 const { user: atprotoUser } = useAtproto() ··· 175 179 176 180 <!-- Navigation links --> 177 181 <div class="flex-1 overflow-y-auto overscroll-contain py-2"> 178 - <!-- App navigation --> 179 - <div class="px-2 py-2"> 180 - <NuxtLink 181 - :to="{ name: 'compare' }" 182 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 183 - @click="closeMenu" 184 - > 185 - <span class="i-carbon:compare w-5 h-5 text-fg-muted" aria-hidden="true" /> 186 - {{ $t('nav.compare') }} 187 - </NuxtLink> 188 - 189 - <NuxtLink 190 - :to="{ name: 'settings' }" 191 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 192 - @click="closeMenu" 193 - > 194 - <span class="i-carbon:settings w-5 h-5 text-fg-muted" aria-hidden="true" /> 195 - {{ $t('nav.settings') }} 196 - </NuxtLink> 197 - 198 - <!-- Connected user links --> 199 - <template v-if="isConnected && npmUser"> 200 - <NuxtLink 201 - :to="{ name: '~username', params: { username: npmUser } }" 202 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 203 - @click="closeMenu" 204 - > 205 - <span class="i-carbon:package w-5 h-5 text-fg-muted" aria-hidden="true" /> 206 - {{ $t('header.packages') }} 207 - </NuxtLink> 208 - 209 - <NuxtLink 210 - :to="{ name: '~username-orgs', params: { username: npmUser } }" 211 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 212 - @click="closeMenu" 213 - > 214 - <span class="i-carbon:enterprise w-5 h-5 text-fg-muted" aria-hidden="true" /> 215 - {{ $t('header.orgs') }} 216 - </NuxtLink> 217 - </template> 218 - </div> 219 - 220 - <!-- Divider --> 221 - <div class="mx-4 my-2 border-t border-border" /> 222 - 223 - <!-- Informational links --> 224 - <div class="px-2 py-2"> 225 - <NuxtLink 226 - :to="{ name: 'about' }" 227 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 228 - @click="closeMenu" 229 - > 230 - <span class="i-carbon:information w-5 h-5 text-fg-muted" aria-hidden="true" /> 231 - {{ $t('footer.about') }} 232 - </NuxtLink> 233 - 234 - <NuxtLink 235 - :to="{ name: 'privacy' }" 236 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 237 - @click="closeMenu" 238 - > 239 - <span class="i-carbon:security w-5 h-5 text-fg-muted" aria-hidden="true" /> 240 - {{ $t('privacy_policy.title') }} 241 - </NuxtLink> 242 - </div> 243 - 244 - <!-- Divider --> 245 - <div class="mx-4 my-2 border-t border-border" /> 246 - 247 - <!-- External links --> 248 - <div class="px-2 py-2"> 249 - <span class="px-3 py-2 font-mono text-xs text-fg-subtle uppercase tracking-wider"> 250 - {{ $t('nav.links') }} 251 - </span> 252 - 253 - <a 254 - href="https://docs.npmx.dev" 255 - target="_blank" 256 - rel="noopener noreferrer" 257 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 258 - > 259 - <span class="i-carbon:document w-5 h-5 text-fg-muted" aria-hidden="true" /> 260 - {{ $t('footer.docs') }} 261 - <span 262 - class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 263 - aria-hidden="true" 264 - /> 265 - </a> 182 + <template v-for="(group, index) in links"> 183 + <div 184 + v-if="group.type === 'separator'" 185 + :key="`seperator-${index}`" 186 + class="mx-4 my-2 border-t border-border" 187 + /> 266 188 267 - <a 268 - href="https://repo.npmx.dev" 269 - target="_blank" 270 - rel="noopener noreferrer" 271 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 272 - > 273 - <span class="i-carbon:logo-github w-5 h-5 text-fg-muted" aria-hidden="true" /> 274 - {{ $t('footer.source') }} 189 + <div v-if="group.type === 'group'" :key="group.name" class="p-2"> 275 190 <span 276 - class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 277 - aria-hidden="true" 278 - /> 279 - </a> 280 - 281 - <a 282 - href="https://social.npmx.dev" 283 - target="_blank" 284 - rel="noopener noreferrer" 285 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 286 - > 287 - <span class="i-simple-icons:bluesky w-5 h-5 text-fg-muted" aria-hidden="true" /> 288 - {{ $t('footer.social') }} 289 - <span 290 - class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 291 - aria-hidden="true" 292 - /> 293 - </a> 294 - 295 - <a 296 - href="https://chat.npmx.dev" 297 - target="_blank" 298 - rel="noopener noreferrer" 299 - class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 300 - > 301 - <span class="i-carbon:chat w-5 h-5 text-fg-muted" aria-hidden="true" /> 302 - {{ $t('footer.chat') }} 303 - <span 304 - class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 305 - aria-hidden="true" 306 - /> 307 - </a> 308 - </div> 191 + v-if="group.label" 192 + class="px-3 py-2 font-mono text-xs text-fg-subtle uppercase tracking-wider" 193 + > 194 + {{ group.label }} 195 + </span> 196 + <div> 197 + <NuxtLink 198 + v-for="link in group.items" 199 + :key="link.name" 200 + :to="link.to" 201 + :href="link.href" 202 + :target="link.target" 203 + class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 204 + @click="closeMenu" 205 + > 206 + <span 207 + :class="link.iconClass" 208 + class="w-5 h-5 text-fg-muted" 209 + aria-hidden="true" 210 + /> 211 + {{ link.label }} 212 + <span 213 + v-if="link.external" 214 + class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 215 + aria-hidden="true" 216 + /> 217 + </NuxtLink> 218 + </div> 219 + </div> 220 + </template> 309 221 </div> 310 222 </nav> 311 223 </Transition>
+1
app/types/index.ts
··· 1 + export * from './navigation'
+28
app/types/navigation.ts
··· 1 + import type { NuxtLinkProps } from '#app' 2 + 3 + export type NavigationLink = { 4 + name: string 5 + label: string 6 + iconClass?: string 7 + to?: NuxtLinkProps['to'] & { name?: string } 8 + href?: string 9 + target?: string 10 + keyshortcut?: string 11 + type: 'link' 12 + external?: boolean 13 + } 14 + 15 + export type NavigationSeparator = { 16 + type: 'separator' 17 + } 18 + 19 + export type NavigationConfig = NavigationLink[] 20 + 21 + export type NavigationGroup = { 22 + name: string 23 + type: 'group' 24 + label?: string 25 + items: NavigationConfig 26 + } 27 + 28 + export type NavigationConfigWithGroups = Array<NavigationGroup | NavigationSeparator>