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

Configure Feed

Select the types of activity you want to include in your feed.

feat: mobile menu (#517)

authored by

Daniel Roe and committed by
GitHub
f8494ef8 7aede3f6

+418 -56
+7 -9
app/components/AppFooter.vue
··· 13 13 <p class="font-mono text-balance m-0 hidden sm:block">{{ $t('tagline') }}</p> 14 14 <BuildEnvironment v-if="!isHome" footer /> 15 15 </div> 16 - <div class="flex flex-wrap items-center gap-x-3 sm:gap-6"> 17 - <NuxtLink 18 - to="/about" 19 - class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center" 20 - > 16 + <!-- Desktop: Show all links. Mobile: Links are in MobileMenu --> 17 + <div class="hidden sm:flex items-center gap-6"> 18 + <NuxtLink to="/about" class="link-subtle font-mono text-xs min-h-11 flex items-center"> 21 19 {{ $t('footer.about') }} 22 20 </NuxtLink> 23 21 <a 24 22 href="https://docs.npmx.dev" 25 23 target="_blank" 26 24 rel="noopener noreferrer" 27 - class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" 25 + class="link-subtle font-mono text-xs min-h-11 flex items-center gap-1" 28 26 > 29 27 {{ $t('footer.docs') }} 30 28 <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> ··· 33 31 href="https://repo.npmx.dev" 34 32 target="_blank" 35 33 rel="noopener noreferrer" 36 - class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" 34 + class="link-subtle font-mono text-xs min-h-11 flex items-center gap-1" 37 35 > 38 36 {{ $t('footer.source') }} 39 37 <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> ··· 42 40 href="https://social.npmx.dev" 43 41 target="_blank" 44 42 rel="noopener noreferrer" 45 - class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" 43 + class="link-subtle font-mono text-xs min-h-11 flex items-center gap-1" 46 44 > 47 45 {{ $t('footer.social') }} 48 46 <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> ··· 51 49 href="https://chat.npmx.dev" 52 50 target="_blank" 53 51 rel="noopener noreferrer" 54 - class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" 52 + class="link-subtle font-mono text-xs min-h-11 flex items-center gap-1" 55 53 > 56 54 {{ $t('footer.chat') }} 57 55 <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" />
+113 -45
app/components/AppHeader.vue
··· 11 11 const { isConnected, npmUser } = useConnector() 12 12 13 13 const showFullSearch = shallowRef(false) 14 + const showMobileMenu = shallowRef(false) 15 + 16 + // On mobile, clicking logo+search button expands search 17 + const route = useRoute() 18 + const isMobile = useIsMobile() 19 + const isSearchExpandedManually = shallowRef(false) 20 + const searchBoxRef = shallowRef<{ focus: () => void } | null>(null) 21 + 22 + // On search page, always show search expanded on mobile 23 + const isOnSearchPage = computed(() => route.name === 'search') 24 + const isSearchExpanded = computed(() => isOnSearchPage.value || isSearchExpandedManually.value) 25 + 26 + function expandMobileSearch() { 27 + isSearchExpandedManually.value = true 28 + nextTick(() => { 29 + searchBoxRef.value?.focus() 30 + }) 31 + } 32 + 33 + function handleSearchBlur() { 34 + showFullSearch.value = false 35 + // Collapse expanded search on mobile after blur (with delay for click handling) 36 + // But don't collapse if we're on the search page 37 + if (isMobile.value && !isOnSearchPage.value) { 38 + setTimeout(() => { 39 + isSearchExpandedManually.value = false 40 + }, 150) 41 + } 42 + } 43 + 44 + function handleSearchFocus() { 45 + showFullSearch.value = true 46 + } 14 47 15 48 onKeyStroke( 16 49 ',', ··· 32 65 <header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"> 33 66 <nav 34 67 :aria-label="$t('nav.main_navigation')" 35 - class="container min-h-14 flex items-center justify-start" 68 + class="container min-h-14 flex items-center justify-between gap-2" 36 69 > 37 - <!-- Start: Logo --> 38 - <div :class="{ 'hidden sm:block': showFullSearch }" class="flex-shrink-0"> 39 - <div v-if="showLogo" class="flex items-center"> 40 - <NuxtLink 41 - to="/" 42 - :aria-label="$t('header.home')" 43 - dir="ltr" 44 - class="inline-flex items-center gap-2 header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded" 45 - > 46 - <img 47 - aria-hidden="true" 48 - :alt="$t('alt_logo')" 49 - src="/logo.svg" 50 - width="96" 51 - height="96" 52 - class="w-8 h-8 rounded-lg" 53 - /> 54 - <span>npmx</span> 55 - </NuxtLink> 56 - </div> 57 - <!-- Spacer when logo is hidden --> 58 - <span v-else class="w-1" /> 70 + <!-- Mobile: Logo + search button (expands search, doesn't navigate) --> 71 + <button 72 + v-if="!isSearchExpanded" 73 + type="button" 74 + 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 focus-ring rounded" 75 + :aria-label="$t('nav.tap_to_search')" 76 + @click="expandMobileSearch" 77 + > 78 + <img 79 + aria-hidden="true" 80 + :alt="$t('alt_logo')" 81 + src="/logo.svg" 82 + width="96" 83 + height="96" 84 + class="w-8 h-8 rounded-lg" 85 + /> 86 + <span class="i-carbon:search w-4 h-4 text-fg-subtle" aria-hidden="true" /> 87 + </button> 88 + 89 + <!-- Desktop: Logo (navigates home) --> 90 + <div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center"> 91 + <NuxtLink 92 + to="/" 93 + :aria-label="$t('header.home')" 94 + dir="ltr" 95 + class="inline-flex items-center gap-2 header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded" 96 + > 97 + <img 98 + aria-hidden="true" 99 + :alt="$t('alt_logo')" 100 + src="/logo.svg" 101 + width="96" 102 + height="96" 103 + class="w-8 h-8 rounded-lg" 104 + /> 105 + <span>npmx</span> 106 + </NuxtLink> 59 107 </div> 108 + <!-- Spacer when logo is hidden on desktop --> 109 + <span v-else class="hidden sm:block w-1" /> 60 110 61 111 <!-- Center: Search bar + nav items --> 62 - <div class="flex-1 flex items-center justify-center md:gap-6 mx-2"> 63 - <!-- Search bar (shown on all pages except home) --> 112 + <div 113 + class="flex-1 flex items-center justify-center md:gap-6" 114 + :class="{ 'hidden sm:flex': !isSearchExpanded }" 115 + > 116 + <!-- Search bar (hidden on mobile unless expanded) --> 64 117 <SearchBox 65 - :inputClass="showFullSearch ? '' : 'max-w[6rem]'" 66 - @focus="showFullSearch = true" 67 - @blur="showFullSearch = false" 118 + ref="searchBoxRef" 119 + :inputClass="isSearchExpanded ? 'w-full' : ''" 120 + :class="{ 'max-w-md': !isSearchExpanded }" 121 + @focus="handleSearchFocus" 122 + @blur="handleSearchBlur" 68 123 /> 69 124 <ul 70 - :class="{ 'hidden sm:flex': showFullSearch }" 71 - class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0" 125 + v-if="!isSearchExpanded" 126 + :class="{ hidden: showFullSearch }" 127 + class="hidden sm:flex items-center gap-4 sm:gap-6 list-none m-0 p-0" 72 128 > 73 129 <!-- Packages dropdown (when connected) --> 74 130 <li v-if="isConnected && npmUser" class="flex items-center"> ··· 82 138 </ul> 83 139 </div> 84 140 85 - <!-- End: User status + GitHub --> 86 - <div 87 - :class="{ 'hidden sm:flex': showFullSearch }" 88 - class="flex-1 flex flex-wrap items-center justify-end sm:gap-3 ms-auto sm:ms-0" 89 - > 90 - <NuxtLink 91 - to="/about" 92 - class="px-2 py-1.5 sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 93 - > 94 - {{ $t('footer.about') }} 95 - </NuxtLink> 96 - 141 + <!-- End: Desktop nav items + Mobile menu button --> 142 + <div class="flex-shrink-0 flex items-center gap-4 sm:gap-6"> 143 + <!-- Desktop: Settings link --> 97 144 <NuxtLink 98 145 to="/settings" 99 - class="link-subtle font-mono text-sm inline-flex items-center gap-2 px-2 py-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 146 + class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 100 147 aria-keyshortcuts="," 101 148 > 102 149 {{ $t('nav.settings') }} 103 150 <kbd 104 - class="hidden sm:inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded" 151 + class="inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded" 105 152 aria-hidden="true" 106 153 > 107 154 , 108 155 </kbd> 109 156 </NuxtLink> 110 157 111 - <HeaderAccountMenu /> 158 + <!-- Desktop: Account menu --> 159 + <div class="hidden sm:block"> 160 + <HeaderAccountMenu /> 161 + </div> 162 + 163 + <!-- Mobile: Menu button (always visible, toggles menu) --> 164 + <button 165 + type="button" 166 + class="sm:hidden p-2 -m-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 167 + :aria-label="showMobileMenu ? $t('common.close') : $t('nav.open_menu')" 168 + :aria-expanded="showMobileMenu" 169 + @click="showMobileMenu = !showMobileMenu" 170 + > 171 + <span 172 + class="w-6 h-6 inline-block" 173 + :class="showMobileMenu ? 'i-carbon:close' : 'i-carbon:menu'" 174 + aria-hidden="true" 175 + /> 176 + </button> 112 177 </div> 113 178 </nav> 179 + 180 + <!-- Mobile menu --> 181 + <MobileMenu v-model:open="showMobileMenu" /> 114 182 </header> 115 183 </template>
+278
app/components/MobileMenu.vue
··· 1 + <script setup lang="ts"> 2 + const isOpen = defineModel<boolean>('open', { default: false }) 3 + 4 + const { isConnected, npmUser, avatar: npmAvatar } = useConnector() 5 + const { user: atprotoUser } = useAtproto() 6 + 7 + const showConnectorModal = shallowRef(false) 8 + const showAuthModal = shallowRef(false) 9 + 10 + function closeMenu() { 11 + isOpen.value = false 12 + } 13 + 14 + function handleShowConnector() { 15 + showConnectorModal.value = true 16 + closeMenu() 17 + } 18 + 19 + function handleShowAuth() { 20 + showAuthModal.value = true 21 + closeMenu() 22 + } 23 + 24 + // Close menu on route change 25 + const route = useRoute() 26 + watch(() => route.fullPath, closeMenu) 27 + 28 + // Close on escape 29 + onKeyStroke('Escape', () => { 30 + if (isOpen.value) { 31 + isOpen.value = false 32 + } 33 + }) 34 + 35 + // Prevent body scroll when menu is open 36 + const isLocked = useScrollLock(document) 37 + watch(isOpen, open => (isLocked.value = open)) 38 + </script> 39 + 40 + <template> 41 + <Teleport to="body"> 42 + <Transition 43 + enter-active-class="transition-opacity duration-200" 44 + leave-active-class="transition-opacity duration-150" 45 + enter-from-class="opacity-0" 46 + leave-to-class="opacity-0" 47 + > 48 + <div 49 + v-if="isOpen" 50 + class="fixed inset-0 z-50 sm:hidden" 51 + role="dialog" 52 + aria-modal="true" 53 + :aria-label="$t('nav.mobile_menu')" 54 + > 55 + <!-- Backdrop --> 56 + <button 57 + type="button" 58 + class="absolute inset-0 bg-black/60 cursor-default" 59 + :aria-label="$t('common.close')" 60 + @click="closeMenu" 61 + /> 62 + 63 + <!-- Menu panel (slides in from right) --> 64 + <Transition 65 + enter-active-class="transition-transform duration-200" 66 + enter-from-class="translate-x-full" 67 + enter-to-class="translate-x-0" 68 + leave-active-class="transition-transform duration-200" 69 + leave-from-class="translate-x-0" 70 + leave-to-class="translate-x-full" 71 + > 72 + <nav 73 + v-if="isOpen" 74 + class="absolute inset-ie-0 top-0 bottom-0 w-72 bg-bg border-is border-border shadow-xl flex flex-col" 75 + > 76 + <!-- Header --> 77 + <div class="flex items-center justify-between p-4 border-b border-border"> 78 + <span class="font-mono text-sm text-fg-muted">{{ $t('nav.menu') }}</span> 79 + <button 80 + type="button" 81 + class="p-2 -m-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 82 + :aria-label="$t('common.close')" 83 + @click="closeMenu" 84 + > 85 + <span class="i-carbon:close block w-5 h-5" aria-hidden="true" /> 86 + </button> 87 + </div> 88 + 89 + <!-- Navigation links --> 90 + <div class="flex-1 overflow-y-auto overscroll-contain py-2"> 91 + <!-- Main navigation --> 92 + <div class="px-2 py-2"> 93 + <NuxtLink 94 + to="/about" 95 + 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" 96 + @click="closeMenu" 97 + > 98 + <span class="i-carbon:information w-5 h-5 text-fg-muted" aria-hidden="true" /> 99 + {{ $t('footer.about') }} 100 + </NuxtLink> 101 + 102 + <NuxtLink 103 + to="/settings" 104 + 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" 105 + @click="closeMenu" 106 + > 107 + <span class="i-carbon:settings w-5 h-5 text-fg-muted" aria-hidden="true" /> 108 + {{ $t('nav.settings') }} 109 + </NuxtLink> 110 + 111 + <!-- Connected user links --> 112 + <template v-if="isConnected && npmUser"> 113 + <NuxtLink 114 + :to="`/~${npmUser}`" 115 + 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" 116 + @click="closeMenu" 117 + > 118 + <span class="i-carbon:package w-5 h-5 text-fg-muted" aria-hidden="true" /> 119 + {{ $t('header.packages') }} 120 + </NuxtLink> 121 + 122 + <NuxtLink 123 + :to="`/~${npmUser}/orgs`" 124 + 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" 125 + @click="closeMenu" 126 + > 127 + <span class="i-carbon:enterprise w-5 h-5 text-fg-muted" aria-hidden="true" /> 128 + {{ $t('header.orgs') }} 129 + </NuxtLink> 130 + </template> 131 + </div> 132 + 133 + <!-- Divider --> 134 + <div class="mx-4 my-2 border-t border-border" /> 135 + 136 + <!-- External links (from footer) --> 137 + <div class="px-2 py-2"> 138 + <span class="px-3 py-2 font-mono text-xs text-fg-subtle uppercase tracking-wider"> 139 + {{ $t('nav.links') }} 140 + </span> 141 + 142 + <a 143 + href="https://docs.npmx.dev" 144 + target="_blank" 145 + rel="noopener noreferrer" 146 + 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" 147 + > 148 + <span class="i-carbon:document w-5 h-5 text-fg-muted" aria-hidden="true" /> 149 + {{ $t('footer.docs') }} 150 + <span 151 + class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 152 + aria-hidden="true" 153 + /> 154 + </a> 155 + 156 + <a 157 + href="https://repo.npmx.dev" 158 + target="_blank" 159 + rel="noopener noreferrer" 160 + 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" 161 + > 162 + <span class="i-carbon:logo-github w-5 h-5 text-fg-muted" aria-hidden="true" /> 163 + {{ $t('footer.source') }} 164 + <span 165 + class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 166 + aria-hidden="true" 167 + /> 168 + </a> 169 + 170 + <a 171 + href="https://social.npmx.dev" 172 + target="_blank" 173 + rel="noopener noreferrer" 174 + 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" 175 + > 176 + <span class="i-carbon:logo-x w-5 h-5 text-fg-muted" aria-hidden="true" /> 177 + {{ $t('footer.social') }} 178 + <span 179 + class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 180 + aria-hidden="true" 181 + /> 182 + </a> 183 + 184 + <a 185 + href="https://chat.npmx.dev" 186 + target="_blank" 187 + rel="noopener noreferrer" 188 + 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" 189 + > 190 + <span class="i-carbon:chat w-5 h-5 text-fg-muted" aria-hidden="true" /> 191 + {{ $t('footer.chat') }} 192 + <span 193 + class="i-carbon:launch rtl-flip w-3 h-3 ms-auto text-fg-subtle" 194 + aria-hidden="true" 195 + /> 196 + </a> 197 + </div> 198 + </div> 199 + 200 + <!-- Divider --> 201 + <div class="mx-4 my-2 border-t border-border" /> 202 + 203 + <!-- Account section --> 204 + <div class="px-2 py-2"> 205 + <span 206 + class="px-3 py-2 block font-mono text-xs text-fg-subtle uppercase tracking-wider" 207 + > 208 + {{ $t('account_menu.account') }} 209 + </span> 210 + 211 + <!-- npm CLI connection status (only show if connected) --> 212 + <button 213 + v-if="isConnected && npmUser" 214 + type="button" 215 + class="w-full 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 text-start" 216 + @click="handleShowConnector" 217 + > 218 + <img 219 + v-if="npmAvatar" 220 + :src="npmAvatar" 221 + :alt="npmUser" 222 + width="20" 223 + height="20" 224 + class="w-5 h-5 rounded-full" 225 + /> 226 + <span 227 + v-else 228 + class="w-5 h-5 rounded-full bg-bg-muted flex items-center justify-center" 229 + > 230 + <span class="i-carbon-terminal w-3 h-3 text-fg-muted" aria-hidden="true" /> 231 + </span> 232 + <span class="flex-1">~{{ npmUser }}</span> 233 + <span class="w-2 h-2 rounded-full bg-green-500" aria-hidden="true" /> 234 + </button> 235 + 236 + <!-- Atmosphere connection status --> 237 + <button 238 + v-if="atprotoUser" 239 + type="button" 240 + class="w-full 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 text-start" 241 + @click="handleShowAuth" 242 + > 243 + <span class="w-5 h-5 rounded-full bg-bg-muted flex items-center justify-center"> 244 + <span class="i-carbon-cloud w-3 h-3 text-fg-muted" aria-hidden="true" /> 245 + </span> 246 + <span class="flex-1 truncate">@{{ atprotoUser.handle }}</span> 247 + </button> 248 + 249 + <!-- Connect Atmosphere button (show if not connected) --> 250 + <button 251 + v-else 252 + type="button" 253 + class="w-full 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 text-start" 254 + @click="handleShowAuth" 255 + > 256 + <span class="w-5 h-5 rounded-full bg-bg-muted flex items-center justify-center"> 257 + <span class="i-carbon-cloud w-3 h-3 text-fg-muted" aria-hidden="true" /> 258 + </span> 259 + <span class="flex-1">{{ $t('account_menu.connect_atmosphere') }}</span> 260 + </button> 261 + </div> 262 + 263 + <!-- Footer --> 264 + <div class="p-4 border-t border-border mt-auto"> 265 + <p class="font-mono text-xs text-fg-subtle text-center"> 266 + {{ $t('non_affiliation_disclaimer') }} 267 + </p> 268 + </div> 269 + </nav> 270 + </Transition> 271 + </div> 272 + </Transition> 273 + 274 + <!-- Modals --> 275 + <ConnectorModal v-model:open="showConnectorModal" /> 276 + <AuthModal v-model:open="showAuthModal" /> 277 + </Teleport> 278 + </template>
+8
app/components/SearchBox.vue
··· 81 81 isSearchFocused.value = true 82 82 emit('focus') 83 83 } 84 + 85 + // Expose focus method for parent components 86 + const inputRef = shallowRef<HTMLInputElement | null>(null) 87 + function focus() { 88 + inputRef.value?.focus() 89 + } 90 + defineExpose({ focus }) 84 91 </script> 85 92 <template> 86 93 <search v-if="showSearchBar" :class="'flex-1 sm:max-w-md ' + inputClass"> ··· 99 106 100 107 <input 101 108 id="header-search" 109 + ref="inputRef" 102 110 :autofocus="!isMobile" 103 111 v-model="searchQuery" 104 112 type="search"
+6 -1
i18n/locales/en.json
··· 46 46 "popular_packages": "Popular packages", 47 47 "search": "search", 48 48 "settings": "settings", 49 - "back": "back" 49 + "back": "back", 50 + "menu": "Menu", 51 + "mobile_menu": "Navigation menu", 52 + "open_menu": "Open menu", 53 + "links": "Links", 54 + "tap_to_search": "Tap to search" 50 55 }, 51 56 "settings": { 52 57 "title": "settings",
+6 -1
lunaria/files/en-US.json
··· 46 46 "popular_packages": "Popular packages", 47 47 "search": "search", 48 48 "settings": "settings", 49 - "back": "back" 49 + "back": "back", 50 + "menu": "Menu", 51 + "mobile_menu": "Navigation menu", 52 + "open_menu": "Open menu", 53 + "links": "Links", 54 + "tap_to_search": "Tap to search" 50 55 }, 51 56 "settings": { 52 57 "title": "settings",