this repo has no description
1<!--
2@component
3Component for rendering an item in a "Hero Carousel" without coupling to any specific data model
4-->
5<script lang="ts">
6 import type { Opt } from '@jet/environment/types/optional';
7 import type {
8 Action,
9 Artwork as ArtworkModel,
10 Color,
11 Video as VideoModel,
12 } from '@jet-app/app-store/api/models';
13
14 import mediaQueries from '~/utils/media-queries';
15 import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion';
16 import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
17
18 import AppIcon from '~/components/AppIcon.svelte';
19 import Artwork from '~/components/Artwork.svelte';
20 import LinkWrapper from '~/components/LinkWrapper.svelte';
21 import Video from '~/components/jet/Video.svelte';
22 import type { NamedProfile } from '~/config/components/artwork';
23 import {
24 colorAsString,
25 getBackgroundGradientCSSVarsFromArtworks,
26 getLuminanceForRGB,
27 } from '~/utils/color';
28 import { isRtl } from '~/utils/locale';
29
30 /**
31 * The main text for the carousel item
32 */
33 export let title: Opt<string> = undefined;
34
35 /**
36 * Additional text above the title.
37 * Note: If a slot is defined with the name `eyebrow`, the slot takes precedence.
38 */
39 export let eyebrow: Opt<string> = undefined;
40
41 /**
42 * Additional text below the title
43 */
44 export let subtitle: Opt<string> = undefined;
45
46 /**
47 * Primary accent color for the carousel item
48 */
49 export let backgroundColor: Opt<Color> = undefined;
50
51 /**
52 * Static artwork to display in the carousel item
53 */
54 export let artwork: Opt<ArtworkModel> = undefined;
55
56 /**
57 * Video to display in the carousel item
58 *
59 * Takes precedence over `artwork`
60 */
61 export let video: Opt<VideoModel> = undefined;
62
63 /**
64 * Action to perform when clicking on the carousel item
65 */
66 export let action: Opt<Action> = undefined;
67
68 /**
69 * Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container
70 */
71 export let pinArtworkToHorizontalEnd: boolean = false;
72
73 /**
74 * Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default)
75 */
76 export let pinArtworkToVerticalMiddle: boolean = false;
77
78 /**
79 * Whether the text (e.g. title, description, etc) should be pinned to the top of the container
80 */
81 export let pinTextToVerticalStart: boolean = false;
82
83 /**
84 * Allows for the absolute overriding of the profile used for the Hero artwork
85 */
86 export let profileOverride: Opt<NamedProfile> = null;
87
88 export let isMediaDark: boolean = true;
89
90 export let collectionIcons: ArtworkModel[] | undefined = undefined;
91
92 let isPortraitLayout: boolean;
93 let profile: NamedProfile;
94 let collectionIconsBackgroundGradientCssVars: string | undefined =
95 undefined;
96
97 $: isPortraitLayout = $mediaQueries === 'xsmall';
98
99 $: {
100 if (profileOverride) {
101 profile = profileOverride;
102 } else if (isPortraitLayout) {
103 profile = 'large-hero-portrait';
104 } else if (pinArtworkToHorizontalEnd && isRtl()) {
105 profile = 'large-hero-east';
106 } else if (pinArtworkToHorizontalEnd) {
107 profile = 'large-hero-west';
108 } else {
109 profile = 'large-hero';
110 }
111 }
112
113 const color: string = backgroundColor
114 ? colorAsString(backgroundColor)
115 : '#000';
116
117 if (collectionIcons && collectionIcons.length > 1) {
118 // If there are multiple app icons, we build a string of CSS variables from the icons
119 // background colors to fill as many of the lockups quadrants as possible.
120 collectionIconsBackgroundGradientCssVars =
121 getBackgroundGradientCSSVarsFromArtworks(collectionIcons, {
122 // sorts from darkest to lightest
123 sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
124 shouldRemoveGreys: true,
125 });
126 }
127</script>
128
129<LinkWrapper {action} includeExternalLinkArrowIcon={false}>
130 <article
131 data-test-id="hero"
132 class:with-dark-media={isMediaDark}
133 class:with-collection-icons={!artwork && !video && collectionIcons}
134 class:text-pinned-to-vertical-start={pinTextToVerticalStart}
135 >
136 {#if video || artwork}
137 <div
138 class={`image-container ${profile}`}
139 class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd}
140 class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle}
141 style:--color={color}
142 >
143 {#if video && !$prefersReducedMotion}
144 <Video
145 loop
146 autoplay
147 useControls={false}
148 {video}
149 {profile}
150 />
151 {:else if artwork}
152 <Artwork
153 {artwork}
154 {profile}
155 noShelfChevronAnchor={true}
156 useCropCodeFromArtwork={false}
157 withoutBorder={true}
158 />
159 {/if}
160 </div>
161 {:else if collectionIcons}
162 <ul class="app-icons">
163 {#each collectionIcons?.slice(0, 5) as collectionIcon}
164 <li class="app-icon-container">
165 <AppIcon
166 icon={collectionIcon}
167 profile="app-icon-large"
168 fixedWidth={false}
169 />
170 </li>
171 {/each}
172 </ul>
173
174 <div
175 class="collection-icons-background-gradient"
176 style={collectionIconsBackgroundGradientCssVars}
177 />
178 {/if}
179
180 <div class="gradient" style="--color: {color};" />
181
182 <slot name="badge" {isPortraitLayout} />
183
184 <div class="metadata-container">
185 {#if $$slots.eyebrow}
186 <h3><slot name="eyebrow" /></h3>
187 {:else if eyebrow}
188 <h3>{eyebrow}</h3>
189 {/if}
190
191 {#if title}
192 <h2>{@html sanitizeHtml(title)}</h2>
193 {/if}
194
195 {#if subtitle}
196 <p class="subtitle">{@html sanitizeHtml(subtitle)}</p>
197 {/if}
198
199 <slot name="details" {isPortraitLayout} />
200 </div>
201 </article>
202</LinkWrapper>
203
204<style lang="scss">
205 @use '@amp/web-shared-styles/app/core/globalvars' as *;
206
207 article {
208 --hero-primary-color: var(--systemPrimary-onLight);
209 --hero-secondary-color: var(--systemSecondary-onLight);
210 --hero-text-blend-mode: normal;
211 --hero-divider-color: var(--systemQuaternary-onLight);
212 position: relative;
213 display: flex;
214 overflow: hidden;
215 align-items: end;
216 aspect-ratio: 3 / 4;
217 container-name: hero-container;
218 container-type: size;
219
220 @media (--range-small-up) {
221 aspect-ratio: 16 / 9;
222 width: 100%;
223 height: auto;
224 min-height: 360px;
225 max-height: min(60vh, 770px);
226 border-radius: var(--global-border-radius-large);
227 border: 1px solid var(--systemQuaternary);
228 }
229 }
230
231 article.with-dark-media,
232 article.with-collection-icons {
233 --hero-primary-color: var(--systemPrimary-onDark);
234 --hero-secondary-color: var(--systemSecondary-onDark);
235 --hero-divider-color: var(--systemQuaternary-onDark);
236 --hero-text-blend-mode: plus-lighter;
237 }
238
239 .image-container {
240 position: absolute;
241 z-index: -1;
242 width: 100%;
243 height: 100%;
244 background-color: var(--color);
245 }
246
247 .image-container.pinned-to-vertical-middle {
248 display: flex;
249 align-items: center;
250 }
251
252 .image-container.pinned-to-vertical-middle :global(.video-container),
253 .image-container.pinned-to-vertical-middle :global(.artwork-component) {
254 width: 100%;
255 height: auto;
256 }
257
258 .image-container.pinned-to-horizontal-end :global(.artwork-component) {
259 height: 100%;
260 display: flex;
261 }
262
263 .image-container.pinned-to-horizontal-end :global(.artwork-component img) {
264 height: 100%;
265 width: auto;
266 position: absolute;
267 inset-inline-end: 0;
268
269 @container hero-container (aspect-ratio >= 279/100) {
270 width: 100%;
271 height: auto;
272 }
273 }
274
275 .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl
276 :global(.artwork-component img) {
277 inset-inline-start: 0;
278 }
279
280 // This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of
281 // 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the
282 // artwork full-width rather than full-height. This should eventually be fixed when Editorial
283 // can prescribe us only 16x9 (1.77:1) hero images.
284 .image-container.pinned-to-horizontal-end.large-hero-story-card,
285 .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl {
286 @container hero-container (aspect-ratio >= 225/100) {
287 :global(.artwork-component img) {
288 width: 100%;
289 height: auto;
290 }
291 }
292 }
293
294 .metadata-container {
295 position: absolute;
296 width: 40%;
297 padding-bottom: 40px;
298 padding-inline-start: 40px;
299 text-wrap: pretty;
300 color: var(--hero-primary-color);
301
302 @media (--range-small-only) {
303 width: 50%;
304 padding: 0 20px 20px;
305 }
306
307 @media (--range-xsmall-down) {
308 width: 100%;
309 padding: 0 20px 20px;
310 text-align: center;
311 }
312 }
313
314 .text-pinned-to-vertical-start .metadata-container {
315 @media (--range-small-only) {
316 top: 20px;
317 }
318
319 @media (--range-medium-up) {
320 top: 40px;
321 }
322 }
323
324 h2 {
325 position: relative;
326 z-index: 1;
327 text-wrap: balance;
328 font: var(--header-emphasized);
329
330 @media (--range-xsmall-down) {
331 font: var(--title-1-emphasized);
332 }
333 }
334
335 @container hero-container (height < 420px) {
336 h2 {
337 font: var(--large-title-emphasized);
338 }
339 }
340
341 h3 {
342 margin-bottom: 8px;
343 position: relative;
344 z-index: 1;
345 color: var(--hero-secondary-color);
346 font: var(--callout-emphasized-tall);
347 mix-blend-mode: var(--hero-text-blend-mode);
348
349 @media (--range-xsmall-down) {
350 margin-bottom: 4px;
351 }
352 }
353
354 p {
355 mix-blend-mode: var(--hero-text-blend-mode);
356 }
357
358 .subtitle {
359 margin-top: 8px;
360 position: relative;
361 z-index: 1;
362 font: var(--body-tall);
363 color: var(--hero-secondary-color);
364 }
365
366 .gradient {
367 --rotation: 55deg;
368
369 &:dir(rtl) {
370 --rotation: -55deg;
371 mask-image: radial-gradient(
372 ellipse 127% 130% at 95% 100%,
373 rgb(0, 0, 0) 18%,
374 rgb(0, 0, 0.33) 24%,
375 rgba(0, 0, 0, 0.66) 32%,
376 transparent 40%
377 ),
378 linear-gradient(
379 -129deg,
380 rgb(0, 0, 0) 0%,
381 rgba(255, 255, 255, 0) 55%
382 );
383 }
384 position: absolute;
385 z-index: -1;
386 width: 100%;
387 height: 100%;
388 // stylelint-disable color-function-notation
389 background: linear-gradient(
390 var(--rotation),
391 rgb(from var(--color) r g b / 0.25) 0%,
392 transparent 50%
393 );
394 // stylelint-enable color-function-notation
395 filter: saturate(1.5) brightness(0.9);
396 backdrop-filter: blur(40px);
397 mask-image: radial-gradient(
398 ellipse 127% 130% at 5% 100%,
399 rgb(0, 0, 0) 18%,
400 rgb(0, 0, 0.33) 24%,
401 rgba(0, 0, 0, 0.66) 32%,
402 transparent 40%
403 ),
404 linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%);
405
406 @media (--range-xsmall-down) {
407 --rotation: 0deg;
408 mask-image: linear-gradient(
409 var(--rotation),
410 rgb(0, 0, 0) 28%,
411 rgba(0, 0, 0, 0) 56%
412 );
413 }
414 }
415
416 // When the text is pinned to the top of the lockup, we use a different gradient for legibility
417 article.text-pinned-to-vertical-start .gradient {
418 --rotation: -170deg;
419 mask-image: radial-gradient(
420 ellipse 118% 121% at 100% 0%,
421 rgb(0, 0, 0) 18%,
422 rgb(0, 0, 0.33) 22%,
423 rgba(0, 0, 0, 0.66) 33%,
424 transparent 43%
425 );
426 }
427
428 .app-icons {
429 display: grid;
430 align-self: center;
431 width: 90%;
432 grid-template-rows: auto auto;
433 grid-auto-flow: column;
434 gap: 24px;
435 margin-inline-start: -4%;
436 position: absolute;
437 inset-inline-end: 24px;
438
439 @media (--range-small-up) {
440 width: 44%;
441 }
442 }
443
444 .app-icons li:nth-child(even) {
445 inset-inline-start: 44%;
446 }
447
448 .app-icon-container {
449 position: relative;
450 flex-shrink: 0;
451 max-width: 200px;
452 }
453
454 @property --top-left-stop {
455 syntax: '<percentage>';
456 inherits: false;
457 initial-value: 20%;
458 }
459
460 @property --bottom-left-stop {
461 syntax: '<percentage>';
462 inherits: false;
463 initial-value: 40%;
464 }
465
466 @property --top-right-stop {
467 syntax: '<percentage>';
468 inherits: false;
469 initial-value: 55%;
470 }
471
472 @property --bottom-right-stop {
473 syntax: '<percentage>';
474 inherits: false;
475 initial-value: 50%;
476 }
477
478 .collection-icons-background-gradient {
479 width: 100%;
480 height: 100%;
481 position: absolute;
482 background: radial-gradient(
483 circle at 3% -50%,
484 var(--top-left, #000) var(--top-left-stop),
485 transparent 70%
486 ),
487 radial-gradient(
488 circle at -50% 120%,
489 var(--bottom-left, #000) var(--bottom-left-stop),
490 transparent 80%
491 ),
492 radial-gradient(
493 circle at 66% -175%,
494 var(--top-right, #000) var(--top-right-stop),
495 transparent 80%
496 ),
497 radial-gradient(
498 circle at 62% 100%,
499 var(--bottom-right, #000) var(--bottom-right-stop),
500 transparent 100%
501 );
502 animation: collection-icons-background-gradient-shift 16s infinite
503 alternate-reverse;
504 animation-play-state: paused;
505
506 @media (--range-small-up) {
507 animation-play-state: running;
508 }
509 }
510
511 @keyframes collection-icons-background-gradient-shift {
512 0% {
513 --top-left-stop: 20%;
514 --bottom-left-stop: 40%;
515 --top-right-stop: 55%;
516 --bottom-right-stop: 50%;
517 background-size: 100% 100%;
518 }
519
520 50% {
521 --top-left-stop: 25%;
522 --bottom-left-stop: 15%;
523 --top-right-stop: 70%;
524 --bottom-right-stop: 30%;
525 background-size: 130% 130%;
526 }
527
528 100% {
529 --top-left-stop: 15%;
530 --bottom-left-stop: 20%;
531 --top-right-stop: 55%;
532 --bottom-right-stop: 20%;
533 background-size: 110% 110%;
534 }
535 }
536</style>