diff --git a/components/AnnotatedImage/AnnotatedImage.styles.ts b/components/AnnotatedImage/AnnotatedImage.styles.ts new file mode 100644 index 00000000..4970ba30 --- /dev/null +++ b/components/AnnotatedImage/AnnotatedImage.styles.ts @@ -0,0 +1,4 @@ +export const root = 'relative self-start'; +export const imageWrapper = 'relative @container'; +export const ul = 'list-unstyled'; +export const li = 'mb-0'; diff --git a/components/AnnotatedImage/AnnotatedImage.tsx b/components/AnnotatedImage/AnnotatedImage.tsx new file mode 100644 index 00000000..32f2094c --- /dev/null +++ b/components/AnnotatedImage/AnnotatedImage.tsx @@ -0,0 +1,60 @@ +import { ImageHotspot } from './ImageHotspot'; +import { Container } from '@/components/Container'; +import { StoryImage, type StoryImageProps } from '@/components/StoryImage'; +import { type MarginType } from '@/utilities/datasource'; +import { type SbImageHotspotType } from '@/components/Storyblok/Storyblok.types'; +import * as styles from './AnnotatedImage.styles'; + +type AnnotatedImageProps = Omit & { + hotspots: SbImageHotspotType[]; + marginTop?: MarginType; + marginBottom?: MarginType; +}; + +export const AnnotatedImage = ({ + hotspots, + imageSrc, + imageFocus, + alt, + aspectRatio, + caption, + isCaptionInset, + captionBgColor, + boundingWidth, + marginTop, + marginBottom, + ...props +}: AnnotatedImageProps) => { + return ( + + {/* Extra div is essential to ensure hotspot doesn't move relative to image when browser is resized */} +
+ + {/* Hotspots */} + {!!hotspots?.length && + (hotspots.length > 1 ? ( +
    + {hotspots.map((hotspot) => ( +
  • + +
  • + ))} +
+ ) : ( + + )) + } +
+
+
+ ); +}; diff --git a/components/AnnotatedImage/ImageHotspot.styles.ts b/components/AnnotatedImage/ImageHotspot.styles.ts new file mode 100644 index 00000000..f35245e1 --- /dev/null +++ b/components/AnnotatedImage/ImageHotspot.styles.ts @@ -0,0 +1,37 @@ +import { cnb } from 'cnbuilder'; +import { type SbImageHotspotModalContentType } from '@/components/Storyblok/Storyblok.types'; + +// To center hotspot on the coordinates, it needs to be offset by half of the hotspot width and height +export const hotspotWrapper = 'absolute -mt-20 -ml-20 @2xl:-mt-25 @2xl:-ml-25'; +export const button = 'group flex relative z-[100] items-center justify-center size-40 @2xl:size-50 bg-black-true/50 rounded-full border-2 border-white hocus-visible:bg-black-true/70 hocus-visible:border-dashed transition-transform hocus-visible:bg-gc-black/80 transition-colors hocus-visible:outline-none focus-visible:ring-4 focus-visible:ring-digital-red-xlight'; +export const icon = 'z-[100] will-change w-24 @2xl:w-30 text-white group-hocus-visible:scale-125 group-aria-expanded:rotate-45 transition-transform'; +export const hotspotRing = 'absolute top-6 left-6 content-[""] size-28 @2xl:size-38 bg-transparent ring-[10px] ring-offset-0 ring-white border-white z-[80] animate-[hotspot_2s_cubic-bezier(0,0,0.2,1)_infinite] rounded-full'; + +// Modal styles +export const dialog = 'relative z-[150]'; +export const srOnly = 'sr-only'; +export const dialogOverlay = 'fixed inset-0 bg-gc-black/60 backdrop-blur-lg w-screen'; +export const dialogWrapper = 'fixed inset-0 sm:py-30 w-screen overflow-y-auto overscroll-contain overflow-x-hidden'; +export const dialogPanel = (modalContentType: SbImageHotspotModalContentType) => cnb( + 'relative sm:cc flex w-screen min-h-screen inset-0 break-words justify-center', + (modalContentType === 'text-image' || modalContentType === 'text') ? 'items-start sm:items-center' : 'items-center text-white', +); +export const modalClose = 'absolute top-20 z-[200] right-20 block mr-0 ml-auto rs-mb-2 p-9 border-2 border-digital-red-xlight bg-black-true rounded-full hocus-visible:border-dashed hocus-visible:border-white transition-transform hocus-visible:rotate-90'; +export const modalIcon = 'text-white size-26'; + +export const grid = 'h-full'; +export const contentWrapper = (modalContentType: SbImageHotspotModalContentType, isVerticalCard: boolean) => cnb('relative flex items-center justify-center', { + 'min-h-screen sm:min-h-[auto] bg-black-true/70 text-white': modalContentType !== 'text-image' && modalContentType !== 'text', + 'bg-white text-black': modalContentType === 'text-image' || modalContentType === 'text', + 'w-full 2xl:aspect-[16/9] 3xl:aspect-2': modalContentType !== 'text' && modalContentType !== 'fullwidth-image' && modalContentType !== 'image-quote' && !isVerticalCard, + 'max-w-1000': modalContentType === 'text-image' && isVerticalCard, +}); +export const textWrapper = (isVerticalCard: boolean) => cnb('pt-90 rs-pb-4 bg-white', !isVerticalCard && 'xl:col-span-6 2xl:col-span-5'); +export const header = 'border-l-[1.2rem] md:border-l-[1.8rem] border-digital-red-light'; +export const heading = 'mb-02em leading-tight ml-22 md:ml-40 2xl:ml-43'; +export const subhead = 'ml-22 md:ml-40 2xl:ml-43'; +export const figure = (isVerticalCard: boolean) => cnb('relative ', !isVerticalCard && 'xl:col-span-6 2xl:col-span-7'); +export const image = 'xl:object-cover w-full xl:h-full'; +export const figcaption = 'md:absolute mt-0 px-18 pt-14 pb-16 bg-black-true/90 md:bg-black-true/70 sm:bottom-20 md:bottom-30 md:left-30 max-w-full md:max-w-[40rem] 2xl:max-w-400s z-[110]'; + +export const nestedComponentWrapper = (modalContentType: SbImageHotspotModalContentType) => modalContentType === 'component' && 'py-100 sm:rs-px-1'; diff --git a/components/AnnotatedImage/ImageHotspot.tsx b/components/AnnotatedImage/ImageHotspot.tsx new file mode 100644 index 00000000..1d7ab34b --- /dev/null +++ b/components/AnnotatedImage/ImageHotspot.tsx @@ -0,0 +1,181 @@ +import { useRef, useState } from 'react'; +import { + Description, Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, +} from '@headlessui/react'; +import { useOnClickOutside } from 'usehooks-ts'; +import { CreateBloks } from '../CreateBloks'; +import { Grid } from '@/components/Grid'; +import { Heading, Text } from '@/components/Typography'; +import { HeroIcon } from '@/components/HeroIcon'; +import { RichText } from '@/components/RichText'; +import { type SbImageHotspotType } from '@/components/Storyblok/Storyblok.types'; +import { getProcessedImage } from '@/utilities/getProcessedImage'; +import { hasRichText } from '@/utilities/hasRichText'; +import { getNumBloks } from '@/utilities/getNumBloks'; +import * as styles from './ImageHotspot.styles'; + +export const ImageHotspot = ({ + positionX: { value: x } = {}, + positionY: { value: y } = {}, + modalContentType, + heading, + ariaLabel, + subhead, + description, + image: { filename, focus } = {}, + caption, + alt, + content, + isVerticalCard, + descriptionSize, +}: SbImageHotspotType) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const buttonRef = useRef(null); + const [isClicked, setIsClicked] = useState(false); + const panelRef = useRef(null); + + const handleClick = () => { + setIsModalOpen(true); + if (!isClicked) { + setIsClicked(true); + } + }; + + useOnClickOutside(panelRef, () => { + setIsModalOpen(false); + }); + + const DescriptionRichText = hasRichText(description) + ? + : undefined; + + const Caption = hasRichText(caption) + ? + : undefined; + + return ( + <> +
+ + {!isClicked && ( +
+ + setIsModalOpen(false)} className={styles.dialog}> + +
+ + +
+ + {heading || ariaLabel} + {subhead && ( + {subhead} + )} +
+ + {/* Content for modals with text+image, fullwidth image or text only */} + {(modalContentType !== 'component' && modalContentType !== 'image-quote') && ( + + {(modalContentType === 'text-image' || modalContentType === 'text') && ( +
+ {(heading || subhead) && ( +
+ {heading && + {heading} + } + {subhead && + {subhead} + } +
+ )} + {DescriptionRichText} +
+ )} + {(modalContentType === 'fullwidth-image' || modalContentType === 'text-image') && filename && ( +
+ + + + + + {alt + + {hasRichText(caption) && ( +
+ {Caption} +
+ )} +
+ )} +
+ )} + {(modalContentType === 'component' || modalContentType === 'image-quote') && !!getNumBloks(content) && ( +
+ +
+ )} +
+
+
+
+
+
+ + ); +}; diff --git a/components/AnnotatedImage/index.ts b/components/AnnotatedImage/index.ts new file mode 100644 index 00000000..eba9da70 --- /dev/null +++ b/components/AnnotatedImage/index.ts @@ -0,0 +1 @@ +export * from './AnnotatedImage'; diff --git a/components/Quote/Quote.styles.ts b/components/Quote/Quote.styles.ts index 28c9001e..b534dc33 100644 --- a/components/Quote/Quote.styles.ts +++ b/components/Quote/Quote.styles.ts @@ -1,24 +1,30 @@ import { cnb } from 'cnbuilder'; +export const verticalAlignments = { + top: 'justify-start', + center: 'justify-center', + bottom: 'justify-end', +}; +export type QuoteVerticalAlignType = keyof typeof verticalAlignments; + export const root = ( - isMinimal?: boolean, - addDarkOverlay?: boolean, - quoteOnRight?: boolean, - hasBar?: boolean, -) => cnb('relative break-words flex flex-col lg:max-w-prose lg:ml-0', { - 'max-w-[90%] sm:max-w-4/5 mx-auto md:max-w-full': isMinimal && !addDarkOverlay, - 'bg-gc-black/50 backdrop-blur-sm h-full *:mt-auto *:mb-0 text-white': addDarkOverlay && !isMinimal, + isMinimal: boolean, + addDarkOverlay: boolean, + quoteOnRight: boolean, + hasBar: boolean, +) => cnb('relative break-words flex flex-col lg:max-w-prose-wide lg:ml-0', { + 'max-w-[90%] sm:max-w-4/5 mx-auto md:max-w-full h-full': isMinimal && !addDarkOverlay, + 'bg-gc-black/50 backdrop-blur-sm h-full *:mb-0 text-white': addDarkOverlay && !isMinimal, 'rs-pl-4': !quoteOnRight && (!isMinimal || addDarkOverlay), 'rs-pr-4': quoteOnRight && (!isMinimal || addDarkOverlay), 'rs-px-4': addDarkOverlay && !hasBar, }); -export const content = (hasBar?: boolean, quoteOnRight?: boolean) => cnb('', { - '' : !hasBar, +export const content = (hasBar: boolean, quoteOnRight: boolean) => cnb({ 'border-r-[1.4rem] 2xl:border-r-[2rem] rs-pr-2' : hasBar && !quoteOnRight, 'border-l-[1.4rem] 2xl:border-l-[2rem] rs-pl-2' : hasBar && quoteOnRight, }); export const teaserWrapper = 'w-fit gap-9'; -export const quoteMark = (isLargeTeaser?: boolean) => cnb('shrink-0 leading-[0]', { +export const quoteMark = (isLargeTeaser: boolean) => cnb('shrink-0 leading-[0]', { 'text-[clamp(16rem,7.46vw+13.31rem,26rem)] mt-[clamp(5.7rem,2.61vw+4.76rem,9.2rem)]': !isLargeTeaser, 'text-[clamp(17rem,11.19vw+12.97rem,32rem)] mt-[clamp(6rem,4.03vw+4.55rem,11.4rem)]': isLargeTeaser, }); diff --git a/components/Quote/Quote.tsx b/components/Quote/Quote.tsx index 2500c8c8..719b9d97 100644 --- a/components/Quote/Quote.tsx +++ b/components/Quote/Quote.tsx @@ -24,6 +24,7 @@ export type QuoteProps = React.HTMLAttributes & { barColor?: AccentBorderColorType; quoteColor?: AccentTextColorType; quoteOnRight?: boolean; + verticalAlign?: styles.QuoteVerticalAlignType; animation?: AnimationType; delay?: number; }; @@ -39,6 +40,7 @@ export const Quote = ({ quoteColor, barColor, quoteOnRight, + verticalAlign = 'bottom', animation = 'slideUp', delay, children, @@ -49,9 +51,12 @@ export const Quote = ({ return (
diff --git a/components/Quote/index.ts b/components/Quote/index.ts index c88e677a..3b978166 100644 --- a/components/Quote/index.ts +++ b/components/Quote/index.ts @@ -1 +1,2 @@ export * from './Quote'; +export * from './Quote.styles'; diff --git a/components/RichText.tsx b/components/RichText.tsx index cdcbdf6d..39d93eba 100644 --- a/components/RichText.tsx +++ b/components/RichText.tsx @@ -17,11 +17,14 @@ import { * -top-04em (used in BlurryPoster) */ -export type RichTextBaseFontSizeType = 'default' | 'card' | 'changemaker' | 'changemakerHorizontal'; +export type RichTextBaseFontSizeType = 'default' | 'card' | 'changemaker' | 'changemakerHorizontal' | 'big'; export type RichTextProps = { wysiwyg: StoryblokRichtext; - // "default" is for main content, e.g., Story body content + /** + * "default" is for main content, e.g., Story body content + * "card" is for card typed content where heading sizes are all type-1 + */ type?: 'default' | 'card'; baseFontSize?: RichTextBaseFontSizeType; textColor?: 'black' | 'white' | 'black-70'; diff --git a/components/StoryImage/StoryImage.styles.ts b/components/StoryImage/StoryImage.styles.ts index 99dc49b6..3dca451a 100644 --- a/components/StoryImage/StoryImage.styles.ts +++ b/components/StoryImage/StoryImage.styles.ts @@ -76,7 +76,7 @@ export type CaptionBgColorType = keyof typeof captionBgColors; export const root = (isFullHeight?: boolean) => cnb(isFullHeight ? 'h-full' : ''); export const animateWrapper = (isFullHeight?: boolean) => cnb(isFullHeight ? 'h-full' : ''); export const figure = (isFullHeight: boolean) => cnb(isFullHeight ? 'h-full' : ''); -export const imageWrapper = (isFullHeight: boolean, isParallax: boolean) => cnb( +export const imageWrapper = (isFullHeight: boolean, isParallax: boolean) => cnb('relative', isFullHeight ? 'h-full' : '', isParallax ? 'overflow-hidden' : '', ); diff --git a/components/StoryImage/StoryImage.tsx b/components/StoryImage/StoryImage.tsx index 4d7ef3e2..fd194ee7 100644 --- a/components/StoryImage/StoryImage.tsx +++ b/components/StoryImage/StoryImage.tsx @@ -9,7 +9,7 @@ import { getProcessedImage } from '@/utilities/getProcessedImage'; import { getSbImageSize } from '@/utilities/getSbImageSize'; import * as styles from './StoryImage.styles'; -type StoryImageProps = React.HTMLAttributes & { +export type StoryImageProps = React.HTMLAttributes & { imageSrc: string; imageFocus?: string; isLoadingEager?: boolean; @@ -45,17 +45,18 @@ export const StoryImage = ({ captionBgColor = 'transparent', animation = 'none', delay, + children, className, ...props }: StoryImageProps) => { const { width: originalWidth, height: originalHeight } = getSbImageSize(imageSrc); - const cropSize = styles.imageCropsDesktop[aspectRatio]; + const cropSize = styles.imageCropsDesktop[aspectRatio] || styles.imageCropsDesktop['free']; /** * Crop width and height are used for width and height attributes on the img element. * They don't need to be exact as long as the aspect ratio is correct. */ const cropWidth = parseInt(cropSize?.split('x')[0], 10); - const cropHeight = aspectRatio === 'free' + const cropHeight = aspectRatio === 'free' || !aspectRatio ? Math.round(originalHeight * 2000 / originalWidth) : parseInt(cropSize?.split('x')[1], 10); @@ -101,6 +102,7 @@ export const StoryImage = ({ )} + {children} {caption && ( { + if (isHidden) { + return null; + } + + const Caption = hasRichText(caption) ? ( + + ) : undefined; + + return ( + + ); +}; diff --git a/components/Storyblok/SbQuote.tsx b/components/Storyblok/SbQuote.tsx index f7bd5c19..66cc0d48 100644 --- a/components/Storyblok/SbQuote.tsx +++ b/components/Storyblok/SbQuote.tsx @@ -1,6 +1,6 @@ import { storyblokEditable } from '@storyblok/react/rsc'; -import { Quote } from '../Quote'; -import { type AnimationType } from '../Animate'; +import { Quote, type QuoteVerticalAlignType } from '@/components/Quote'; +import { type AnimationType } from '@/components/Animate'; import { paletteAccentColors, type PaletteAccentHexColorType, @@ -23,6 +23,7 @@ export type SbQuoteProps = { quoteColor?: { value?: PaletteAccentHexColorType; } + verticalAlign?: QuoteVerticalAlignType; animation?: AnimationType; delay?: number; }; @@ -40,6 +41,7 @@ export const SbQuote = ({ barColor: { value } = {}, quoteOnRight, quoteColor: { value: quoteColorValue } = {}, + verticalAlign, animation, delay, }, @@ -58,6 +60,7 @@ export const SbQuote = ({ barColor={paletteAccentColors[value]} quoteOnRight={quoteOnRight} quoteColor={paletteAccentColors[quoteColorValue]} + verticalAlign={verticalAlign} animation={animation} delay={delay} /> diff --git a/components/Storyblok/Storyblok.types.ts b/components/Storyblok/Storyblok.types.ts index 07cea09c..353975e8 100644 --- a/components/Storyblok/Storyblok.types.ts +++ b/components/Storyblok/Storyblok.types.ts @@ -1,4 +1,6 @@ +import { type SbBlokData } from '@storyblok/react/rsc'; import { type FontSizeType } from '@/components/Typography'; +import { type StoryblokRichtext } from 'storyblok-rich-text-react-renderer-ts'; /** * Generic types for Storyblok fields @@ -72,3 +74,28 @@ export type SbColorStopProps = { export type SbColorPickerType = { color?: string; }; + +export type SbSliderType = { + value?: number; +} + +// Used for Annotated Image component +export type SbImageHotspotModalContentType = 'text-image' | 'fullwidth-image' | 'text' | 'component' | 'image-quote'; +export type SbImageHotspotDescriptionSizeType = 'card' | 'default' | 'big'; + +export type SbImageHotspotType = { + _uid: string; + positionX: SbSliderType; + positionY: SbSliderType; + modalContentType: SbImageHotspotModalContentType; + heading: string; + ariaLabel: string; + subhead: string; + description: StoryblokRichtext; + image: SbImageType; + caption: StoryblokRichtext; + alt: string; + content: SbBlokData[]; + isVerticalCard: boolean; + descriptionSize: SbImageHotspotDescriptionSizeType; +}; diff --git a/components/StoryblokProvider.tsx b/components/StoryblokProvider.tsx index a7dcd1c9..eee4eff2 100644 --- a/components/StoryblokProvider.tsx +++ b/components/StoryblokProvider.tsx @@ -1,5 +1,6 @@ 'use client'; import { storyblokInit, apiPlugin } from '@storyblok/react/rsc'; +import { SbAnnotatedImage } from './Storyblok/SbAnnotatedImage'; import { SbBanner } from '@/components/Storyblok/SbBanner'; import { SbBasicCard } from '@/components/Storyblok/SbBasicCard'; import { SbBasicPage } from '@/components/Storyblok/SbBasicPage'; @@ -43,6 +44,7 @@ import { SbWysiwyg } from '@/components/Storyblok/SbWysiwyg'; import ComponentNotFound from '@/components/Storyblok/ComponentNotFound'; export const components = { + sbAnnotatedImage: SbAnnotatedImage, sbBanner: SbBanner, sbBasicCard: SbBasicCard, sbBasicPage: SbBasicPage, diff --git a/tailwind.config.ts b/tailwind.config.ts index f71636f4..cfdc9231 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -24,6 +24,7 @@ export default { colors: require(`${dir}/theme/gc-colors.js`)(), fontFamily: require(`${dir}/theme/gc-fontFamily.js`)(), lineHeight: require(`${dir}/theme/gc-lineHeight.js`)(), + keyframes: require(`${dir}/theme/gc-keyframes.js`)(), screens: require(`${dir}/theme/gc-screens.js`)(), }, }, diff --git a/tailwind/plugins/theme/gc-keyframes.js b/tailwind/plugins/theme/gc-keyframes.js new file mode 100644 index 00000000..423db2f0 --- /dev/null +++ b/tailwind/plugins/theme/gc-keyframes.js @@ -0,0 +1,13 @@ +/** + * Momentum keyframe animations + */ +module.exports = function () { + return { + hotspot: { + '75%, 100%': { + transform: 'scale(1.4)', + opacity: '0', + }, + } + } +};