summaryrefslogtreecommitdiff
path: root/src/components/hero
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/hero')
-rw-r--r--src/components/hero/AppLockupDetail.svelte109
-rw-r--r--src/components/hero/Carousel.svelte132
-rw-r--r--src/components/hero/CarouselBackgroundPortal.svelte17
-rw-r--r--src/components/hero/Hero.svelte536
4 files changed, 794 insertions, 0 deletions
diff --git a/src/components/hero/AppLockupDetail.svelte b/src/components/hero/AppLockupDetail.svelte
new file mode 100644
index 0000000..e4abe47
--- /dev/null
+++ b/src/components/hero/AppLockupDetail.svelte
@@ -0,0 +1,109 @@
+<!--
+@component
+Component for rendering App information into the `details` slot
+of the `Hero.svelte` component
+-->
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+
+ import { getI18n } from '~/stores/i18n';
+ import AppIcon from '~/components/AppIcon.svelte';
+
+ const i18n = getI18n();
+
+ export let lockup: Lockup;
+ export let isOnDarkBackground: boolean = true;
+</script>
+
+<div class="lockup-container">
+ {#if lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={lockup.icon} profile="app-icon-small" />
+ </div>
+ {/if}
+
+ <div class="text-container">
+ {#if lockup.heading}
+ <LineClamp clamp={1}>
+ <h4>{lockup.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p>{lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span
+ class="get-button"
+ class:transparent={isOnDarkBackground}
+ class:dark-gray={!isOnDarkBackground}
+ >
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+</div>
+
+<style lang="scss">
+ .lockup-container {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ max-width: 350px;
+ margin-top: 20px;
+ padding-top: 20px;
+ color: var(--hero-primary-color, var(--systemPrimary-onDark));
+ border-top: 1px solid
+ var(--hero-divider-color, var(--systemQuaternary-onDark));
+
+ @media (--range-xsmall-down) {
+ text-align: left;
+ padding: 20px 0 10px;
+ max-width: unset;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 64px;
+ margin-inline-end: 16px;
+ }
+
+ .text-container {
+ width: 100%;
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--title-3-emphasized);
+ text-wrap: pretty;
+ }
+
+ h4 {
+ color: var(--hero-secondary-color, var(--systemSecondary-onDark));
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
+ }
+
+ p {
+ mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
+ }
+
+ .button-container {
+ --get-button-font: var(--title-3-bold);
+ position: relative;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/hero/Carousel.svelte b/src/components/hero/Carousel.svelte
new file mode 100644
index 0000000..218813b
--- /dev/null
+++ b/src/components/hero/Carousel.svelte
@@ -0,0 +1,132 @@
+<!--
+@component
+Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from
+any particular data model
+-->
+<script lang="ts" generics="Item">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { Artwork, Shelf } from '@jet-app/app-store/api/models';
+
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import mediaQueries from '~/utils/media-queries';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+ import HeroCarouselBackgroundPortal, {
+ id as portalId,
+ } from '~/components/hero/CarouselBackgroundPortal.svelte';
+ import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte';
+ import portal from '~/utils/portal';
+ import { carouselMediaStyle } from '~/stores/carousel-media-style';
+
+ interface $$Slots {
+ default: {
+ /**
+ * The `Item` to render as a `Hero` in the carousel
+ */
+ item: Item;
+ };
+ }
+
+ /**
+ * The shelf being rendered
+ *
+ * Used to derrive any shelf-specific presentation
+ */
+ export let shelf: Shelf;
+
+ /**
+ * The items to render in the hero carousel
+ *
+ * This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself,
+ * the set of items that we need to present; some shelves model their items as chilren
+ * of the first shelf item.
+ */
+ export let items: Item[];
+
+ /**
+ * Callback that determines the "background artwork" to use behind the
+ * active `Hero` for the given `Item`
+ */
+ export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>;
+
+ $: gridRows = shelf.rowsPerColumn ?? undefined;
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+
+ let activeIndex: number | undefined = 0;
+
+ function createIntersectionObserverCallback(index: number) {
+ return (isIntersectingViewport: boolean) => {
+ if (isIntersectingViewport) {
+ // Many different types of `item`s can be rendered in this Carousel, and all those
+ // different items have different ways of determining whether or not the background
+ // is dark or light, so we are running through all the options here.
+ const { style, mediaOverlayStyle, isMediaDark } = items[
+ index
+ ] as any;
+ const fallbackStyle = 'dark';
+ let derivedStyle;
+
+ if (typeof isMediaDark !== 'undefined') {
+ derivedStyle = isMediaDark ? 'dark' : 'light';
+ }
+
+ carouselMediaStyle.set(
+ style || mediaOverlayStyle || derivedStyle || fallbackStyle,
+ );
+
+ activeIndex = index;
+ }
+ };
+ }
+</script>
+
+<HeroCarouselBackgroundPortal />
+
+<ShelfWrapper {shelf} --shelfGridGutterWidth="0">
+ <HorizontalShelf
+ {gridRows}
+ {items}
+ --shelfScrollPaddingInline="0"
+ --grid-max-content-xsmall={!$sidebarIsHidden
+ ? 'calc(100% + 50px)'
+ : '100vw'}
+ gridType="Spotlight"
+ let:item
+ let:index
+ >
+ {#if isXSmallViewport}
+ <div
+ use:intersectionObserver={{
+ callback: createIntersectionObserverCallback(index),
+ threshold: 0.5,
+ }}
+ >
+ <slot {item} />
+ </div>
+ {:else}
+ <div
+ use:intersectionObserver={{
+ callback: createIntersectionObserverCallback(index),
+ threshold: 0,
+ }}
+ >
+ {#if !import.meta.env.SSR}
+ {@const backgroundArtwork =
+ deriveBackgroundArtworkFromItem(item)}
+
+ {#if backgroundArtwork}
+ <div use:portal={portalId}>
+ <AmbientBackgroundArtwork
+ artwork={backgroundArtwork}
+ active={activeIndex === index}
+ />
+ </div>
+ {/if}
+ {/if}
+
+ <slot {item} />
+ </div>
+ {/if}
+ </HorizontalShelf>
+</ShelfWrapper>
diff --git a/src/components/hero/CarouselBackgroundPortal.svelte b/src/components/hero/CarouselBackgroundPortal.svelte
new file mode 100644
index 0000000..4580ce0
--- /dev/null
+++ b/src/components/hero/CarouselBackgroundPortal.svelte
@@ -0,0 +1,17 @@
+<script lang="ts" context="module">
+ export const id = 'hero-carousel-shelf-background-portal';
+</script>
+
+<div {id} />
+
+<style>
+ #hero-carousel-shelf-background-portal {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ z-index: -1;
+ }
+</style>
diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte
new file mode 100644
index 0000000..f643ffa
--- /dev/null
+++ b/src/components/hero/Hero.svelte
@@ -0,0 +1,536 @@
+<!--
+@component
+Component for rendering an item in a "Hero Carousel" without coupling to any specific data model
+-->
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type {
+ Action,
+ Artwork as ArtworkModel,
+ Color,
+ Video as VideoModel,
+ } from '@jet-app/app-store/api/models';
+
+ import mediaQueries from '~/utils/media-queries';
+ import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import {
+ colorAsString,
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+
+ /**
+ * The main text for the carousel item
+ */
+ export let title: Opt<string> = undefined;
+
+ /**
+ * Additional text above the title.
+ * Note: If a slot is defined with the name `eyebrow`, the slot takes precedence.
+ */
+ export let eyebrow: Opt<string> = undefined;
+
+ /**
+ * Additional text below the title
+ */
+ export let subtitle: Opt<string> = undefined;
+
+ /**
+ * Primary accent color for the carousel item
+ */
+ export let backgroundColor: Opt<Color> = undefined;
+
+ /**
+ * Static artwork to display in the carousel item
+ */
+ export let artwork: Opt<ArtworkModel> = undefined;
+
+ /**
+ * Video to display in the carousel item
+ *
+ * Takes precedence over `artwork`
+ */
+ export let video: Opt<VideoModel> = undefined;
+
+ /**
+ * Action to perform when clicking on the carousel item
+ */
+ export let action: Opt<Action> = undefined;
+
+ /**
+ * Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container
+ */
+ export let pinArtworkToHorizontalEnd: boolean = false;
+
+ /**
+ * Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default)
+ */
+ export let pinArtworkToVerticalMiddle: boolean = false;
+
+ /**
+ * Whether the text (e.g. title, description, etc) should be pinned to the top of the container
+ */
+ export let pinTextToVerticalStart: boolean = false;
+
+ /**
+ * Allows for the absolute overriding of the profile used for the Hero artwork
+ */
+ export let profileOverride: Opt<NamedProfile> = null;
+
+ export let isMediaDark: boolean = true;
+
+ export let collectionIcons: ArtworkModel[] | undefined = undefined;
+
+ let isPortraitLayout: boolean;
+ let profile: NamedProfile;
+ let collectionIconsBackgroundGradientCssVars: string | undefined =
+ undefined;
+
+ $: isPortraitLayout = $mediaQueries === 'xsmall';
+
+ $: {
+ if (profileOverride) {
+ profile = profileOverride;
+ } else if (isPortraitLayout) {
+ profile = 'large-hero-portrait';
+ } else if (pinArtworkToHorizontalEnd && isRtl()) {
+ profile = 'large-hero-east';
+ } else if (pinArtworkToHorizontalEnd) {
+ profile = 'large-hero-west';
+ } else {
+ profile = 'large-hero';
+ }
+ }
+
+ const color: string = backgroundColor
+ ? colorAsString(backgroundColor)
+ : '#000';
+
+ if (collectionIcons && collectionIcons.length > 1) {
+ // If there are multiple app icons, we build a string of CSS variables from the icons
+ // background colors to fill as many of the lockups quadrants as possible.
+ collectionIconsBackgroundGradientCssVars =
+ getBackgroundGradientCSSVarsFromArtworks(collectionIcons, {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ shouldRemoveGreys: true,
+ });
+ }
+</script>
+
+<LinkWrapper {action} includeExternalLinkArrowIcon={false}>
+ <article
+ data-test-id="hero"
+ class:with-dark-media={isMediaDark}
+ class:with-collection-icons={!artwork && !video && collectionIcons}
+ class:text-pinned-to-vertical-start={pinTextToVerticalStart}
+ >
+ {#if video || artwork}
+ <div
+ class={`image-container ${profile}`}
+ class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd}
+ class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle}
+ style:--color={color}
+ >
+ {#if video && !$prefersReducedMotion}
+ <Video
+ loop
+ autoplay
+ useControls={false}
+ {video}
+ {profile}
+ />
+ {:else if artwork}
+ <Artwork
+ {artwork}
+ {profile}
+ noShelfChevronAnchor={true}
+ useCropCodeFromArtwork={false}
+ withoutBorder={true}
+ />
+ {/if}
+ </div>
+ {:else if collectionIcons}
+ <ul class="app-icons">
+ {#each collectionIcons?.slice(0, 5) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ <div
+ class="collection-icons-background-gradient"
+ style={collectionIconsBackgroundGradientCssVars}
+ />
+ {/if}
+
+ <div class="gradient" style="--color: {color};" />
+
+ <slot name="badge" {isPortraitLayout} />
+
+ <div class="metadata-container">
+ {#if $$slots.eyebrow}
+ <h3><slot name="eyebrow" /></h3>
+ {:else if eyebrow}
+ <h3>{eyebrow}</h3>
+ {/if}
+
+ {#if title}
+ <h2>{@html sanitizeHtml(title)}</h2>
+ {/if}
+
+ {#if subtitle}
+ <p class="subtitle">{@html sanitizeHtml(subtitle)}</p>
+ {/if}
+
+ <slot name="details" {isPortraitLayout} />
+ </div>
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ --hero-primary-color: var(--systemPrimary-onLight);
+ --hero-secondary-color: var(--systemSecondary-onLight);
+ --hero-text-blend-mode: normal;
+ --hero-divider-color: var(--systemQuaternary-onLight);
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ align-items: end;
+ aspect-ratio: 3 / 4;
+ container-name: hero-container;
+ container-type: size;
+
+ @media (--range-small-up) {
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ height: auto;
+ min-height: 360px;
+ max-height: min(60vh, 770px);
+ border-radius: var(--global-border-radius-large);
+ border: 1px solid var(--systemQuaternary);
+ }
+ }
+
+ article.with-dark-media,
+ article.with-collection-icons {
+ --hero-primary-color: var(--systemPrimary-onDark);
+ --hero-secondary-color: var(--systemSecondary-onDark);
+ --hero-divider-color: var(--systemQuaternary-onDark);
+ --hero-text-blend-mode: plus-lighter;
+ }
+
+ .image-container {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ background-color: var(--color);
+ }
+
+ .image-container.pinned-to-vertical-middle {
+ display: flex;
+ align-items: center;
+ }
+
+ .image-container.pinned-to-vertical-middle :global(.video-container),
+ .image-container.pinned-to-vertical-middle :global(.artwork-component) {
+ width: 100%;
+ height: auto;
+ }
+
+ .image-container.pinned-to-horizontal-end :global(.artwork-component) {
+ height: 100%;
+ display: flex;
+ }
+
+ .image-container.pinned-to-horizontal-end :global(.artwork-component img) {
+ height: 100%;
+ width: auto;
+ position: absolute;
+ inset-inline-end: 0;
+
+ @container hero-container (aspect-ratio >= 279/100) {
+ width: 100%;
+ height: auto;
+ }
+ }
+
+ .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl
+ :global(.artwork-component img) {
+ inset-inline-start: 0;
+ }
+
+ // This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of
+ // 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the
+ // artwork full-width rather than full-height. This should eventually be fixed when Editorial
+ // can prescribe us only 16x9 (1.77:1) hero images.
+ .image-container.pinned-to-horizontal-end.large-hero-story-card,
+ .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl {
+ @container hero-container (aspect-ratio >= 225/100) {
+ :global(.artwork-component img) {
+ width: 100%;
+ height: auto;
+ }
+ }
+ }
+
+ .metadata-container {
+ position: absolute;
+ width: 40%;
+ padding-bottom: 40px;
+ padding-inline-start: 40px;
+ text-wrap: pretty;
+ color: var(--hero-primary-color);
+
+ @media (--range-small-only) {
+ width: 50%;
+ padding: 0 20px 20px;
+ }
+
+ @media (--range-xsmall-down) {
+ width: 100%;
+ padding: 0 20px 20px;
+ text-align: center;
+ }
+ }
+
+ .text-pinned-to-vertical-start .metadata-container {
+ @media (--range-small-only) {
+ top: 20px;
+ }
+
+ @media (--range-medium-up) {
+ top: 40px;
+ }
+ }
+
+ h2 {
+ position: relative;
+ z-index: 1;
+ text-wrap: balance;
+ font: var(--header-emphasized);
+
+ @media (--range-xsmall-down) {
+ font: var(--title-1-emphasized);
+ }
+ }
+
+ @container hero-container (height < 420px) {
+ h2 {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h3 {
+ margin-bottom: 8px;
+ position: relative;
+ z-index: 1;
+ color: var(--hero-secondary-color);
+ font: var(--callout-emphasized-tall);
+ mix-blend-mode: var(--hero-text-blend-mode);
+
+ @media (--range-xsmall-down) {
+ margin-bottom: 4px;
+ }
+ }
+
+ p {
+ mix-blend-mode: var(--hero-text-blend-mode);
+ }
+
+ .subtitle {
+ margin-top: 8px;
+ position: relative;
+ z-index: 1;
+ font: var(--body-tall);
+ color: var(--hero-secondary-color);
+ }
+
+ .gradient {
+ --rotation: 55deg;
+
+ &:dir(rtl) {
+ --rotation: -55deg;
+ mask-image: radial-gradient(
+ ellipse 127% 130% at 95% 100%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 24%,
+ rgba(0, 0, 0, 0.66) 32%,
+ transparent 40%
+ ),
+ linear-gradient(
+ -129deg,
+ rgb(0, 0, 0) 0%,
+ rgba(255, 255, 255, 0) 55%
+ );
+ }
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ // stylelint-disable color-function-notation
+ background: linear-gradient(
+ var(--rotation),
+ rgb(from var(--color) r g b / 0.25) 0%,
+ transparent 50%
+ );
+ // stylelint-enable color-function-notation
+ filter: saturate(1.5) brightness(0.9);
+ backdrop-filter: blur(40px);
+ mask-image: radial-gradient(
+ ellipse 127% 130% at 5% 100%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 24%,
+ rgba(0, 0, 0, 0.66) 32%,
+ transparent 40%
+ ),
+ linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%);
+
+ @media (--range-xsmall-down) {
+ --rotation: 0deg;
+ mask-image: linear-gradient(
+ var(--rotation),
+ rgb(0, 0, 0) 28%,
+ rgba(0, 0, 0, 0) 56%
+ );
+ }
+ }
+
+ // When the text is pinned to the top of the lockup, we use a different gradient for legibility
+ article.text-pinned-to-vertical-start .gradient {
+ --rotation: -170deg;
+ mask-image: radial-gradient(
+ ellipse 118% 121% at 100% 0%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 22%,
+ rgba(0, 0, 0, 0.66) 33%,
+ transparent 43%
+ );
+ }
+
+ .app-icons {
+ display: grid;
+ align-self: center;
+ width: 90%;
+ grid-template-rows: auto auto;
+ grid-auto-flow: column;
+ gap: 24px;
+ margin-inline-start: -4%;
+ position: absolute;
+ inset-inline-end: 24px;
+
+ @media (--range-small-up) {
+ width: 44%;
+ }
+ }
+
+ .app-icons li:nth-child(even) {
+ inset-inline-start: 44%;
+ }
+
+ .app-icon-container {
+ position: relative;
+ flex-shrink: 0;
+ max-width: 200px;
+ }
+
+ @property --top-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 20%;
+ }
+
+ @property --bottom-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 40%;
+ }
+
+ @property --top-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 55%;
+ }
+
+ @property --bottom-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 50%;
+ }
+
+ .collection-icons-background-gradient {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) var(--top-left-stop),
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) var(--bottom-left-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 66% -175%,
+ var(--top-right, #000) var(--top-right-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) var(--bottom-right-stop),
+ transparent 100%
+ );
+ animation: collection-icons-background-gradient-shift 16s infinite
+ alternate-reverse;
+ animation-play-state: paused;
+
+ @media (--range-small-up) {
+ animation-play-state: running;
+ }
+ }
+
+ @keyframes collection-icons-background-gradient-shift {
+ 0% {
+ --top-left-stop: 20%;
+ --bottom-left-stop: 40%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 50%;
+ background-size: 100% 100%;
+ }
+
+ 50% {
+ --top-left-stop: 25%;
+ --bottom-left-stop: 15%;
+ --top-right-stop: 70%;
+ --bottom-right-stop: 30%;
+ background-size: 130% 130%;
+ }
+
+ 100% {
+ --top-left-stop: 15%;
+ --bottom-left-stop: 20%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 20%;
+ background-size: 110% 110%;
+ }
+ }
+</style>