diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /src/components/navigation/Navigation.svelte | |
init commit
Diffstat (limited to 'src/components/navigation/Navigation.svelte')
| -rw-r--r-- | src/components/navigation/Navigation.svelte | 423 |
1 files changed, 423 insertions, 0 deletions
diff --git a/src/components/navigation/Navigation.svelte b/src/components/navigation/Navigation.svelte new file mode 100644 index 0000000..0114d4c --- /dev/null +++ b/src/components/navigation/Navigation.svelte @@ -0,0 +1,423 @@ +<script lang="ts"> + import { writable } from 'svelte/store'; + import { isSome } from '@jet/environment/types/optional'; + import type { + WebNavigation, + WebNavigationLink, + } from '@jet-app/app-store/api/models/web-navigation'; + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; + + import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + + import AppStoreLogo from '~/components/icons/AppStoreLogo.svg'; + import PlatformSelectorDropdown from '~/components/jet/web-navigation/PlatformSelectorDropdown.svelte'; + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + + import { getJetPerform } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import { + type NavigationItemWithTab, + navigationIdFromLink, + makeNavLinks, + } from '~/components/navigation/navigation-items'; + import mediaQueries from '~/utils/media-queries'; + + import { fade, type EasingFunction } from 'svelte/transition'; + import { circOut } from 'svelte/easing'; + import { flyAndBlur } from '~/utils/transition'; + import { makeCategoryTabsIntent } from '@jet-app/app-store/api/intents/category-tabs-intent'; + import { getJet } from '~/jet'; + import { getPlatformFromPage } from '~/utils/seo/common'; + import type { NavigationId } from '@amp/web-app-components/src/types'; + + const i18n = getI18n(); + const perform = getJetPerform(); + const jet = getJet(); + + const categoryTabsCache: Record<string, WebNavigationLink[]> = {}; + let categoryTabLinks: WebNavigationLink[] = []; + let currentTabStore = writable<NavigationId | null>(null); + + export let webNavigation: WebNavigation; + + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: searchAction = webNavigation.searchAction as WebSearchFlowAction; + // Mobile first means the inline items are hidden + // However, we still want the list visible in SSR (which is fine for mobile + // since the menu won't be expanded by default) + $: inlinePlatformItems = + isXSmallViewport || typeof window === 'undefined' + ? webNavigation.platforms + : []; + + $: if (webNavigation && typeof window !== 'undefined') { + fetchCategoryTabs(webNavigation); + } + + async function fetchCategoryTabs(nav: WebNavigation) { + const platform = getPlatformFromPage({ + webNavigation: nav, + }); + + if (!platform) { + categoryTabLinks = []; + return; + } + + if (categoryTabsCache[platform]) { + categoryTabLinks = updateActiveStates(categoryTabsCache[platform]); + } else { + try { + const data = await jet.dispatch( + makeCategoryTabsIntent({ + platform, + }), + ); + + categoryTabsCache[platform] = data; + categoryTabLinks = updateActiveStates(data); + } catch (error) { + categoryTabLinks = []; + } + } + + updateCurrentTab(); + } + + function updateActiveStates( + tabs: WebNavigationLink[], + ): WebNavigationLink[] { + return tabs.map((link) => ({ + ...link, + isActive: link.action?.destination?.id + ? window.location.pathname.includes(link.action.destination.id) + : false, + })); + } + + function updateCurrentTab() { + const allLinks: WebNavigationLink[] = [ + ...categoryTabLinks, + ...webNavigation.tabs, + ]; + + const activeLink = allLinks.find((link) => link.isActive); + currentTabStore.set( + activeLink ? navigationIdFromLink(activeLink) : null, + ); + } + + function handleMenuItemClick(event: CustomEvent<NavigationItemWithTab>) { + const navigationItem = event.detail; + const tab = navigationItem.tab; + + perform(tab.action); + } + + const BASE_DELAY = 80; + const BASE_DURATION = 150; + const DURATION_SPREAD = 300; + + // Returns an eased duration for a list item based on its index, e.g. items later in the list + // get longer durations, between BASE_DURATION and BASE_DURATION + DURATION_SPREAD. + function getEasedDuration({ + i, + totalNumberOfItems, + easing = circOut, + }: { + i: number; + totalNumberOfItems: number; + easing?: EasingFunction; + }) { + const t = i / (totalNumberOfItems - 1); + return BASE_DURATION + easing(t) * DURATION_SPREAD; + } +</script> + +<div class="navigation-wrapper"> + <Navigation + translateFn={$i18n.t} + items={makeNavLinks(webNavigation.tabs, { + shouldShowSearchTab: $sidebarIsHidden, + })} + personalizedItemsHeader={$i18n.t( + 'ASE.Web.AppStore.Navigation.Categories.Title', + )} + personalizedItems={makeNavLinks(categoryTabLinks, { + shouldShowSearchTab: $sidebarIsHidden, + })} + currentTab={currentTabStore} + libraryItems={[]} + on:menuItemClick={handleMenuItemClick} + > + <div slot="logo" class="platform-selector-container"> + <span + id="app-store-icon-contianer" + class="app-store-icon-container" + role="img" + aria-label={$i18n.t( + 'ASE.Web.AppStore.Navigation.AX.AppStoreLogo', + )} + > + <AppStoreLogo focusable={false} /> + </span> + + {#if !$sidebarIsHidden && !isXSmallViewport} + <PlatformSelectorDropdown + platformSelectors={webNavigation.platforms} + /> + {/if} + </div> + + <svelte:fragment slot="search"> + <div class="search-input-container"> + <SearchInput {searchAction} /> + </div> + </svelte:fragment> + + <div slot="after-navigation-items" class="platform-selector-inline"> + {#if isXSmallViewport} + <h3 in:fade out:fade={{ delay: 250, duration: BASE_DURATION }}> + {$i18n.t('ASE.Web.AppStore.Navigation.PlatformHeading')} + </h3> + {/if} + + <ul> + {#each inlinePlatformItems as platformSelector, i (platformSelector.action.title)} + {@const { action, isActive } = platformSelector} + {@const artwork = action.artwork} + {@const totalNumberOfItems = inlinePlatformItems.length} + <li + in:flyAndBlur={{ + y: -50, + delay: i * BASE_DELAY, + duration: getEasedDuration({ + i, + totalNumberOfItems, + }), + }} + out:flyAndBlur={{ + y: i * -5, + delay: + // This delay is calculated in a negative/backwards manner, + // which makes it so the items build out from the bottom to the top. + (totalNumberOfItems - i - 1) * (BASE_DELAY / 2), + duration: BASE_DURATION, + }} + > + <FlowAction destination={action}> + <span class="platform" class:is-active={isActive}> + {#if isSome(artwork) && isSystemImageArtwork(artwork)} + <div + class="icon-container" + aria-hidden="true" + > + <SystemImage {artwork} /> + </div> + {/if} + + <span class="platform-title"> + {action.title} + </span> + + {#if action.destination && isSearchResultsPageIntent(action.destination)} + <span + aria-hidden={true} + class="search-icon-container" + > + <SFSymbol name="magnifyingglass" /> + </span> + {/if} + </span> + </FlowAction> + </li> + {/each} + </ul> + </div> + </Navigation> +</div> + +<style lang="scss"> + .navigation-wrapper { + display: contents; + } + + .platform-selector-container { + --header-gap: 3px; + --platform-selector-trigger-gap: var(--header-gap); + display: flex; + gap: var(--header-gap); + position: relative; + + @media (--sidebar-visible) { + padding: 19px 25px 14px; + } + } + + // Japanese and Catalonian both require scaling down the platform selector in order to make it + // fit cleanly in the sidebar, due to their longer character lengths. + .platform-selector-container:lang(ja), + .platform-selector-container:lang(ca) { + --scale-factor: 0.1; + z-index: 3; + transform: scale(calc(1 - var(--scale-factor))); + transform-origin: center left; + + & :global(dialog) { + top: 60px; + // Since the `dialog` is a child of `platform-selector-container, we re-scale it back + // to it's original size by applying the inverse scale transformation. + transform: scale(calc(1 + var(--scale-factor))); + transform-origin: center left; + } + } + + .app-store-icon-container { + display: flex; + align-items: center; + gap: var(--header-gap); + font: var(--title-1); + font-weight: 600; + } + + .app-store-icon-container :global(svg) { + height: 18px; + position: relative; + top: 0.33px; + width: auto; + + @media (--sidebar-visible) and (--range-xsmall-only) { + height: 22px; + width: auto; + } + } + + .search-input-container { + margin: 0 25px; + } + + .navigation-wrapper :global(.navigation__header) { + @media (--sidebar-visible) { + display: flex; + flex-direction: column; + } + } + + .navigation-wrapper :global(.navigation-item__link) { + height: 100%; + display: flex; + } + + .navigation-wrapper :global(.navigation-item__icon) { + --navigation-item-icon-size: 32px; + width: var(--navigation-item-icon-size); + height: var(--navigation-item-icon-size); + display: flex; + justify-content: center; + + @media (--sidebar-visible) { + --navigation-item-icon-size: 24px; + } + } + + // Our SVG icons for the landing pages are sized differently than other Onyx apps, + // so we have to reach into the navigation component and style them so they look + // visually similar to the other Onyx apps + .navigation-wrapper :global(.navigation-item__icon svg) { + color: var(--keyColor); + width: 20px; + + @media (--sidebar-visible) { + width: 18px; + } + } + + // Below is styling for the "inline" version of the Platform Selector + .platform-selector-inline { + margin: 8px 32px; + } + + ul { + display: flex; + flex-direction: column; + gap: 5px; + } + + h3 { + color: var(--systemTertiary); + font: var(--body-emphasized); + margin: 0 0 10px; + padding-top: 20px; + + @media (--sidebar-visible) { + font: var(--footnote-emphasized); + margin: 0 0 6px; + padding-top: 7px; + } + } + + .platform { + display: flex; + gap: 10px; + padding: 8px 0; + color: var(--systemTertiary); + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary); + } + } + + .platform, + .platform :global(svg) { + transition: color 210ms ease-out; + } + + .platform:not(.is-active):hover, + .platform:not(.is-active):hover :global(svg) { + color: var(--systemPrimary); + } + + .platform.is-active { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .platform.is-active :global(svg) { + color: currentColor; + } + + .icon-container { + display: flex; + } + + .icon-container :global(svg) { + color: var(--systemTertiary); + width: 18px; + max-height: 16px; + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary); + } + } + + .search-icon-container { + display: flex; + } + + .search-icon-container :global(svg) { + fill: var(--systemSecondary); + width: 16px; + } + + .platform-title { + font: var(--body); + flex-grow: 1; + } +</style> |
