Navigation.svelte
1 <script lang="ts"> 2 import { writable } from 'svelte/store'; 3 import { isSome } from '@jet/environment/types/optional'; 4 import type { 5 WebNavigation, 6 WebNavigationLink, 7 } from '@jet-app/app-store/api/models/web-navigation'; 8 import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; 9 import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; 10 11 import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte'; 12 import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; 13 14 import AppStoreLogo from '~/components/icons/AppStoreLogo.svg'; 15 import PlatformSelectorDropdown from '~/components/jet/web-navigation/PlatformSelectorDropdown.svelte'; 16 import FlowAction from '~/components/jet/action/FlowAction.svelte'; 17 import SystemImage, { 18 isSystemImageArtwork, 19 } from '~/components/SystemImage.svelte'; 20 import SearchInput from '~/components/navigation/SearchInput.svelte'; 21 import SFSymbol from '~/components/SFSymbol.svelte'; 22 23 import { getJetPerform } from '~/jet'; 24 import { getI18n } from '~/stores/i18n'; 25 import { 26 type NavigationItemWithTab, 27 navigationIdFromLink, 28 makeNavLinks, 29 } from '~/components/navigation/navigation-items'; 30 import mediaQueries from '~/utils/media-queries'; 31 32 import { fade, type EasingFunction } from 'svelte/transition'; 33 import { circOut } from 'svelte/easing'; 34 import { flyAndBlur } from '~/utils/transition'; 35 import { makeCategoryTabsIntent } from '@jet-app/app-store/api/intents/category-tabs-intent'; 36 import { getJet } from '~/jet'; 37 import { getPlatformFromPage } from '~/utils/seo/common'; 38 import type { NavigationId } from '@amp/web-app-components/src/types'; 39 40 const i18n = getI18n(); 41 const perform = getJetPerform(); 42 const jet = getJet(); 43 44 const categoryTabsCache: Record<string, WebNavigationLink[]> = {}; 45 let categoryTabLinks: WebNavigationLink[] = []; 46 let currentTabStore = writable<NavigationId | null>(null); 47 48 export let webNavigation: WebNavigation; 49 50 $: isXSmallViewport = $mediaQueries === 'xsmall'; 51 $: searchAction = webNavigation.searchAction as WebSearchFlowAction; 52 // Mobile first means the inline items are hidden 53 // However, we still want the list visible in SSR (which is fine for mobile 54 // since the menu won't be expanded by default) 55 $: inlinePlatformItems = 56 isXSmallViewport || typeof window === 'undefined' 57 ? webNavigation.platforms 58 : []; 59 60 $: if (webNavigation && typeof window !== 'undefined') { 61 fetchCategoryTabs(webNavigation); 62 } 63 64 async function fetchCategoryTabs(nav: WebNavigation) { 65 const platform = getPlatformFromPage({ 66 webNavigation: nav, 67 }); 68 69 if (!platform) { 70 categoryTabLinks = []; 71 return; 72 } 73 74 if (categoryTabsCache[platform]) { 75 categoryTabLinks = updateActiveStates(categoryTabsCache[platform]); 76 } else { 77 try { 78 const data = await jet.dispatch( 79 makeCategoryTabsIntent({ 80 platform, 81 }), 82 ); 83 84 categoryTabsCache[platform] = data; 85 categoryTabLinks = updateActiveStates(data); 86 } catch (error) { 87 categoryTabLinks = []; 88 } 89 } 90 91 updateCurrentTab(); 92 } 93 94 function updateActiveStates( 95 tabs: WebNavigationLink[], 96 ): WebNavigationLink[] { 97 return tabs.map((link) => ({ 98 ...link, 99 isActive: link.action?.destination?.id 100 ? window.location.pathname.includes(link.action.destination.id) 101 : false, 102 })); 103 } 104 105 function updateCurrentTab() { 106 const allLinks: WebNavigationLink[] = [ 107 ...categoryTabLinks, 108 ...webNavigation.tabs, 109 ]; 110 111 const activeLink = allLinks.find((link) => link.isActive); 112 currentTabStore.set( 113 activeLink ? navigationIdFromLink(activeLink) : null, 114 ); 115 } 116 117 function handleMenuItemClick(event: CustomEvent<NavigationItemWithTab>) { 118 const navigationItem = event.detail; 119 const tab = navigationItem.tab; 120 121 perform(tab.action); 122 } 123 124 const BASE_DELAY = 80; 125 const BASE_DURATION = 150; 126 const DURATION_SPREAD = 300; 127 128 // Returns an eased duration for a list item based on its index, e.g. items later in the list 129 // get longer durations, between BASE_DURATION and BASE_DURATION + DURATION_SPREAD. 130 function getEasedDuration({ 131 i, 132 totalNumberOfItems, 133 easing = circOut, 134 }: { 135 i: number; 136 totalNumberOfItems: number; 137 easing?: EasingFunction; 138 }) { 139 const t = i / (totalNumberOfItems - 1); 140 return BASE_DURATION + easing(t) * DURATION_SPREAD; 141 } 142 </script> 143 144 <div class="navigation-wrapper"> 145 <Navigation 146 translateFn={$i18n.t} 147 items={makeNavLinks(webNavigation.tabs, { 148 shouldShowSearchTab: $sidebarIsHidden, 149 })} 150 personalizedItemsHeader={$i18n.t( 151 'ASE.Web.AppStore.Navigation.Categories.Title', 152 )} 153 personalizedItems={makeNavLinks(categoryTabLinks, { 154 shouldShowSearchTab: $sidebarIsHidden, 155 })} 156 currentTab={currentTabStore} 157 libraryItems={[]} 158 on:menuItemClick={handleMenuItemClick} 159 > 160 <div slot="logo" class="platform-selector-container"> 161 <span 162 id="app-store-icon-contianer" 163 class="app-store-icon-container" 164 role="img" 165 aria-label={$i18n.t( 166 'ASE.Web.AppStore.Navigation.AX.AppStoreLogo', 167 )} 168 > 169 <AppStoreLogo focusable={false} /> 170 </span> 171 172 {#if !$sidebarIsHidden && !isXSmallViewport} 173 <PlatformSelectorDropdown 174 platformSelectors={webNavigation.platforms} 175 /> 176 {/if} 177 </div> 178 179 <svelte:fragment slot="search"> 180 <div class="search-input-container"> 181 <SearchInput {searchAction} /> 182 </div> 183 </svelte:fragment> 184 185 <div slot="after-navigation-items" class="platform-selector-inline"> 186 {#if isXSmallViewport} 187 <h3 in:fade out:fade={{ delay: 250, duration: BASE_DURATION }}> 188 {$i18n.t('ASE.Web.AppStore.Navigation.PlatformHeading')} 189 </h3> 190 {/if} 191 192 <ul> 193 {#each inlinePlatformItems as platformSelector, i (platformSelector.action.title)} 194 {@const { action, isActive } = platformSelector} 195 {@const artwork = action.artwork} 196 {@const totalNumberOfItems = inlinePlatformItems.length} 197 <li 198 in:flyAndBlur={{ 199 y: -50, 200 delay: i * BASE_DELAY, 201 duration: getEasedDuration({ 202 i, 203 totalNumberOfItems, 204 }), 205 }} 206 out:flyAndBlur={{ 207 y: i * -5, 208 delay: 209 // This delay is calculated in a negative/backwards manner, 210 // which makes it so the items build out from the bottom to the top. 211 (totalNumberOfItems - i - 1) * (BASE_DELAY / 2), 212 duration: BASE_DURATION, 213 }} 214 > 215 <FlowAction destination={action}> 216 <span class="platform" class:is-active={isActive}> 217 {#if isSome(artwork) && isSystemImageArtwork(artwork)} 218 <div 219 class="icon-container" 220 aria-hidden="true" 221 > 222 <SystemImage {artwork} /> 223 </div> 224 {/if} 225 226 <span class="platform-title"> 227 {action.title} 228 </span> 229 230 {#if action.destination && isSearchResultsPageIntent(action.destination)} 231 <span 232 aria-hidden={true} 233 class="search-icon-container" 234 > 235 <SFSymbol name="magnifyingglass" /> 236 </span> 237 {/if} 238 </span> 239 </FlowAction> 240 </li> 241 {/each} 242 </ul> 243 </div> 244 </Navigation> 245 </div> 246 247 <style lang="scss"> 248 .navigation-wrapper { 249 display: contents; 250 } 251 252 .platform-selector-container { 253 --header-gap: 3px; 254 --platform-selector-trigger-gap: var(--header-gap); 255 display: flex; 256 gap: var(--header-gap); 257 position: relative; 258 259 @media (--sidebar-visible) { 260 padding: 19px 25px 14px; 261 } 262 } 263 264 // Japanese and Catalonian both require scaling down the platform selector in order to make it 265 // fit cleanly in the sidebar, due to their longer character lengths. 266 .platform-selector-container:lang(ja), 267 .platform-selector-container:lang(ca) { 268 --scale-factor: 0.1; 269 z-index: 3; 270 transform: scale(calc(1 - var(--scale-factor))); 271 transform-origin: center left; 272 273 & :global(dialog) { 274 top: 60px; 275 // Since the `dialog` is a child of `platform-selector-container, we re-scale it back 276 // to it's original size by applying the inverse scale transformation. 277 transform: scale(calc(1 + var(--scale-factor))); 278 transform-origin: center left; 279 } 280 } 281 282 .app-store-icon-container { 283 display: flex; 284 align-items: center; 285 gap: var(--header-gap); 286 font: var(--title-1); 287 font-weight: 600; 288 } 289 290 .app-store-icon-container :global(svg) { 291 height: 18px; 292 position: relative; 293 top: 0.33px; 294 width: auto; 295 296 @media (--sidebar-visible) and (--range-xsmall-only) { 297 height: 22px; 298 width: auto; 299 } 300 } 301 302 .search-input-container { 303 margin: 0 25px; 304 } 305 306 .navigation-wrapper :global(.navigation__header) { 307 @media (--sidebar-visible) { 308 display: flex; 309 flex-direction: column; 310 } 311 } 312 313 .navigation-wrapper :global(.navigation-item__link) { 314 height: 100%; 315 display: flex; 316 } 317 318 .navigation-wrapper :global(.navigation-item__icon) { 319 --navigation-item-icon-size: 32px; 320 width: var(--navigation-item-icon-size); 321 height: var(--navigation-item-icon-size); 322 display: flex; 323 justify-content: center; 324 325 @media (--sidebar-visible) { 326 --navigation-item-icon-size: 24px; 327 } 328 } 329 330 // Our SVG icons for the landing pages are sized differently than other Onyx apps, 331 // so we have to reach into the navigation component and style them so they look 332 // visually similar to the other Onyx apps 333 .navigation-wrapper :global(.navigation-item__icon svg) { 334 color: var(--keyColor); 335 width: 20px; 336 337 @media (--sidebar-visible) { 338 width: 18px; 339 } 340 } 341 342 // Below is styling for the "inline" version of the Platform Selector 343 .platform-selector-inline { 344 margin: 8px 32px; 345 } 346 347 ul { 348 display: flex; 349 flex-direction: column; 350 gap: 5px; 351 } 352 353 h3 { 354 color: var(--systemTertiary); 355 font: var(--body-emphasized); 356 margin: 0 0 10px; 357 padding-top: 20px; 358 359 @media (--sidebar-visible) { 360 font: var(--footnote-emphasized); 361 margin: 0 0 6px; 362 padding-top: 7px; 363 } 364 } 365 366 .platform { 367 display: flex; 368 gap: 10px; 369 padding: 8px 0; 370 color: var(--systemTertiary); 371 372 @media (prefers-color-scheme: dark) { 373 color: var(--systemSecondary); 374 } 375 } 376 377 .platform, 378 .platform :global(svg) { 379 transition: color 210ms ease-out; 380 } 381 382 .platform:not(.is-active):hover, 383 .platform:not(.is-active):hover :global(svg) { 384 color: var(--systemPrimary); 385 } 386 387 .platform.is-active { 388 color: var(--systemPrimary); 389 font: var(--body-emphasized); 390 } 391 392 .platform.is-active :global(svg) { 393 color: currentColor; 394 } 395 396 .icon-container { 397 display: flex; 398 } 399 400 .icon-container :global(svg) { 401 color: var(--systemTertiary); 402 width: 18px; 403 max-height: 16px; 404 405 @media (prefers-color-scheme: dark) { 406 color: var(--systemSecondary); 407 } 408 } 409 410 .search-icon-container { 411 display: flex; 412 } 413 414 .search-icon-container :global(svg) { 415 fill: var(--systemSecondary); 416 width: 16px; 417 } 418 419 .platform-title { 420 font: var(--body); 421 flex-grow: 1; 422 } 423 </style>