/ src / components / navigation / Navigation.svelte
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>