Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DS-850 | Annotated image component #343

Merged
merged 43 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ff53f25
initial commit
yvonnetangsu Sep 5, 2024
6acaaa8
Merge branch 'dev' into feature/DS-850_annotated-image
yvonnetangsu Sep 5, 2024
462e3fa
Set up SbAnnotatedImage
yvonnetangsu Sep 5, 2024
e69c3e2
linter
yvonnetangsu Sep 5, 2024
3c15731
Hotspots proto
yvonnetangsu Sep 6, 2024
d44b86d
Use modal instead of popover; hotspot button style
yvonnetangsu Sep 6, 2024
80e2db1
Update headlessui
yvonnetangsu Sep 6, 2024
7f22656
Allow nested components
yvonnetangsu Sep 6, 2024
bd6101b
Modal content update
yvonnetangsu Sep 7, 2024
8c2c70b
modal styles
yvonnetangsu Sep 20, 2024
cf1627d
Handle hotspot animation
yvonnetangsu Sep 20, 2024
2ab114f
Redo hotspot animation based on feedback
yvonnetangsu Sep 20, 2024
d807f44
text and image variant for the modal
yvonnetangsu Sep 22, 2024
481ea20
More modal adjustments
yvonnetangsu Sep 22, 2024
ec07c14
More modal
yvonnetangsu Sep 24, 2024
72b5b18
Center hotspots
yvonnetangsu Sep 24, 2024
4f2fe3a
Responsive and make sure it works when placed in a > 1 col grid
yvonnetangsu Sep 24, 2024
9685ca9
Fix issue with bounding width and moving hotspots
yvonnetangsu Sep 24, 2024
23e4ad8
Use container query for hotspot size instead of media query
yvonnetangsu Sep 25, 2024
c98be6f
Make sure hotspot relative position doesn't shift due to caption
yvonnetangsu Sep 25, 2024
cbcedae
description font size options
yvonnetangsu Sep 25, 2024
3651cc2
Variant with image only
yvonnetangsu Sep 25, 2024
b0a9852
Properly check for caption
yvonnetangsu Sep 26, 2024
9268ac8
Update next and netlify next plugin first
yvonnetangsu Sep 26, 2024
d064f0d
Update React, netlify-cli
yvonnetangsu Sep 26, 2024
93f4d6f
Update typescript and types
yvonnetangsu Sep 26, 2024
d8e19a1
Update @storyblok/react
yvonnetangsu Sep 26, 2024
49066c3
Update headlessui
yvonnetangsu Sep 26, 2024
42b3b23
Update framer motion and heroicons
yvonnetangsu Sep 26, 2024
8d179cf
Update autoprefixer, postcss and react-loading-skeleton
yvonnetangsu Sep 26, 2024
822afdd
update madr
yvonnetangsu Sep 26, 2024
c41f9d0
Merge branch 'feature/DS-911_update-dependencies' into feature/DS-850…
yvonnetangsu Sep 26, 2024
c7bf1ad
Merge branch 'dev' into feature/DS-850_annotated-image
yvonnetangsu Sep 27, 2024
7d90141
Merge branch 'dev' into feature/DS-850_annotated-image
yvonnetangsu Oct 10, 2024
bda7e10
Add test only modal variant
yvonnetangsu Oct 10, 2024
e5055ba
Add quote vertical alignment option like on Tour site; style text-onl…
yvonnetangsu Oct 10, 2024
27665fd
linter
yvonnetangsu Oct 10, 2024
d07ac89
Make sure crop height and width are defined even if nothing is passed…
yvonnetangsu Oct 10, 2024
72e8e1c
further clean up styles
yvonnetangsu Oct 11, 2024
050aa40
Image only variant
yvonnetangsu Oct 11, 2024
a58d09e
undo my mess up for min height
yvonnetangsu Oct 11, 2024
c632cc9
More options and variants
yvonnetangsu Oct 11, 2024
2ccacf4
Final clean up with styles and image source sizes
yvonnetangsu Oct 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions components/AnnotatedImage/AnnotatedImage.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const root = 'relative self-start';
export const ul = 'list-unstyled';
export const li = 'mb-0';
60 changes: 60 additions & 0 deletions components/AnnotatedImage/AnnotatedImage.tsx
Original file line number Diff line number Diff line change
@@ -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<StoryImageProps, 'width' | 'isParallax' | 'animation' | 'delay' | 'isFullHeight' | 'spacingTop' | 'spacingBottom'> & {
hotspots: SbImageHotspotType[];
marginTop?: MarginType;
marginBottom?: MarginType;
};

export const AnnotatedImage = ({
hotspots,
imageSrc,
imageFocus,
alt,
aspectRatio,
caption,
isCaptionInset,
captionBgColor,
boundingWidth,
marginTop,
marginBottom,
...props
}: AnnotatedImageProps) => {
return (
<Container width={boundingWidth} mt={marginTop} mb={marginBottom} className={styles.root} {...props}>
{/* Extra div is essential to ensure hotspot doesn't move relative to image when browser is resized */}
<div className="relative @container">
<StoryImage
imageSrc={imageSrc}
imageFocus={imageFocus}
alt={alt}
aspectRatio={aspectRatio}
width="12"
caption={caption}
isCaptionInset={isCaptionInset}
captionBgColor={captionBgColor}
>
{/* Hotspots */}
{!!hotspots?.length &&
(hotspots.length > 1 ? (
<ul className={styles.ul}>
{hotspots.map((hotspot) => (
<li key={hotspot.ariaLabel} className={styles.li}>
<ImageHotspot {...hotspot} />
</li>
))}
</ul>
) : (
<ImageHotspot {...hotspots[0]} />
))
}
</StoryImage>
</div>
</Container>
);
};
27 changes: 27 additions & 0 deletions components/AnnotatedImage/ImageHotspot.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 contentWrapper = (modalContentType: SbImageHotspotModalContentType) => cnb('relative flex items-center justify-center', {
'min-h-screen sm:min-h-0': modalContentType !== 'text-image' && modalContentType !== 'text',
'bg-white text-black': modalContentType === 'text-image' || modalContentType === 'text',
'bg-black-true/70 text-white': modalContentType !== 'text-image' && modalContentType !== 'text',
'w-full 2xl:aspect-[16/9] 3xl:aspect-2': modalContentType !== 'text' && modalContentType !== 'fullwidth-image',
});
177 changes: 177 additions & 0 deletions components/AnnotatedImage/ImageHotspot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { useRef, useState } from 'react';
import {
Description, Dialog, DialogPanel, DialogTitle, Transition, TransitionChild,
} from '@headlessui/react';
import { useOnClickOutside } from 'usehooks-ts';
import { cnb } from 'cnbuilder';
import { CreateBloks } from '../CreateBloks';
import { Grid } from '@/components/Grid';
import { FlexBox } from '@/components/FlexBox';
import { Heading, Text } from '@/components/Typography';
import { HeroIcon } from '@/components/HeroIcon';
import { RichText } from '@/components/RichText';
import { StoryImage } from '@/components/StoryImage';
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<HTMLButtonElement>(null);
const [isClicked, setIsClicked] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);

const handleClick = () => {
setIsModalOpen(true);
if (!isClicked) {
setIsClicked(true);
}
};

useOnClickOutside(panelRef, () => {
setIsModalOpen(false);
});

const DescriptionRichText = hasRichText(description)
? <RichText wysiwyg={description} baseFontSize={descriptionSize} className="rs-mt-3 *:max-w-prose *:leading-snug rs-px-4" />
: undefined;

const Caption = hasRichText(caption)
? <RichText wysiwyg={caption} textColor="white" linkColor="digital-red-xlight" className="first:*:mt-0 *:leading-display" />
: undefined;

return (
<>
<div className={styles.hotspotWrapper} style={{ top: `${y * 100}%`, left: `${x * 100}%` }}>
<button
type="button"
ref={buttonRef}
onClick={handleClick}
aria-haspopup="dialog"
aria-label={`Open modal ${ariaLabel || heading}`}
className={styles.button}
>
<HeroIcon noBaseStyle icon="plus" strokeWidth={2} className={styles.icon} />
</button>
{!isClicked && (
<span aria-hidden="true" className={styles.hotspotRing} />
)}
</div>
<Transition show={isModalOpen}>
<Dialog onClose={() => setIsModalOpen(false)} className={styles.dialog}>
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className={styles.dialogOverlay} />
</TransitionChild>
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className={styles.dialogWrapper}>
<DialogPanel className={styles.dialogPanel(modalContentType)}>
<DialogTitle className={styles.srOnly}>{heading || ariaLabel}</DialogTitle>
{subhead && (
<Description className={styles.srOnly}>{subhead}</Description>
)}
<div ref={panelRef} className={styles.contentWrapper(modalContentType)}>
<button
type="button"
aria-label="Close modal"
onClick={() => setIsModalOpen(false)}
className={styles.modalClose}
>
<HeroIcon
noBaseStyle
focusable="false"
strokeWidth={2}
icon='close'
className={styles.modalIcon}
/>
</button>
{modalContentType !== 'component' && (
<Grid xl={modalContentType === 'text-image' ? 12 : 1} className="h-full">
{(modalContentType === 'text-image' || modalContentType === 'text') && (
<div className="xl:col-span-6 2xl:col-span-5 pt-90 rs-pb-4 bg-white">
<div className="border-l-[1.2rem] md:border-l-[1.8rem] border-digital-red-light">
<Heading size={3} className="mb-02em leading-tight ml-22 md:ml-40 2xl:ml-43">{heading}</Heading>
<Text as="span" weight="semibold" className="ml-22 md:ml-40 2xl:ml-43">{subhead}</Text>
</div>
{DescriptionRichText}
</div>
)}
{(modalContentType === 'fullwidth-image' || modalContentType === 'text-image') && (
<figure className="relative xl:col-span-6 2xl:col-span-7">
<picture>
<source
src={getProcessedImage(filename, '1500x750', focus)}
media="(min-width: 1500px)"
/>
<source
src={getProcessedImage(filename, '1200x600', focus)}
media="(max-width: 1199px)"
/>
<source
media="(max-width: 991px)"
src={getProcessedImage(filename, '1000x500', focus)}
/>
<source
media="(max-width: 575px)"
src={getProcessedImage(filename, '600x300', focus)}
/>
<img
alt={alt || ''}
src={getProcessedImage(filename, '1500x750', focus)}
className="xl:object-cover w-full xl:h-full"
width="1500"
height="750"
/>
</picture>
{hasRichText(caption) && (
<figcaption className="sm:absolute mt-0 px-18 pt-14 pb-16 bg-black-true/90 sm:bg-black-true/70 sm:bottom-20 sm:left-20 md:bottom-30 md:left-30 max-w-full sm:max-w-[30rem] md:max-w-[40rem] 2xl:max-w-400s z-[110]">
{Caption}
</figcaption>
)}
</figure>
)}
</Grid>
)}
{modalContentType === 'component' && !!getNumBloks(content) && (
<div className="py-100 sm:rs-px-1">
<CreateBloks blokSection={content} />
</div>
)}
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
</>
);
};
1 change: 1 addition & 0 deletions components/AnnotatedImage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './AnnotatedImage';
23 changes: 15 additions & 8 deletions components/Quote/Quote.styles.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
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', {
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': isMinimal && !addDarkOverlay,
'bg-gc-black/50 backdrop-blur-sm h-full *:mt-auto *:mb-0 text-white': addDarkOverlay && !isMinimal,
'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('', {
export const content = (hasBar: boolean, quoteOnRight: boolean) => cnb('', {
'' : !hasBar,
'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,
});
Expand Down
8 changes: 7 additions & 1 deletion components/Quote/Quote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type QuoteProps = React.HTMLAttributes<HTMLDivElement> & {
barColor?: AccentBorderColorType;
quoteColor?: AccentTextColorType;
quoteOnRight?: boolean;
verticalAlign?: styles.QuoteVerticalAlignType;
animation?: AnimationType;
delay?: number;
};
Expand All @@ -39,6 +40,7 @@ export const Quote = ({
quoteColor,
barColor,
quoteOnRight,
verticalAlign = 'bottom',
animation = 'slideUp',
delay,
children,
Expand All @@ -51,7 +53,11 @@ export const Quote = ({
width="full"
pt={addDarkOverlay && !isMinimal ? 6 : undefined}
pb={addDarkOverlay && !isMinimal ? 4 : undefined}
className={cnb(styles.root(isMinimal, addDarkOverlay, quoteOnRight, !!barColor), className)}
className={cnb(
styles.root(isMinimal, addDarkOverlay, quoteOnRight, !!barColor),
styles.verticalAlignments[verticalAlign],
className,
)}
{...props}
>
<blockquote className={cnb(styles.content(!!barColor, quoteOnRight), accentBorderColors[barColor])}>
Expand Down
1 change: 1 addition & 0 deletions components/Quote/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './Quote';
export * from './Quote.styles';
7 changes: 5 additions & 2 deletions components/RichText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion components/StoryImage/StoryImage.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' : '',
);
Expand Down
Loading
Loading