From 6be90eec69f9d9b7b87a8fd0bc5d5d636d852a5d Mon Sep 17 00:00:00 2001 From: Yvonne Tang <42749717+yvonnetangsu@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:20:36 -0800 Subject: [PATCH] DS-1021 | Image Slider Gallery (#381) undefined --- app/(storyblok)/error.tsx | 6 +- app/global-error.tsx | 6 +- app/globals.css | 4 + app/layout.tsx | 1 + components/FlexBox/FlexBox.tsx | 12 +- components/HeroIcon/HeroIcon.styles.tsx | 5 + components/ImageSlider/ImageSlider.styles.ts | 42 +++ components/ImageSlider/ImageSlider.tsx | 297 ++++++++++++++++++ .../ImageSlider/NextPrevButton.styles.ts | 11 + components/ImageSlider/NextPrevButton.tsx | 30 ++ components/ImageSlider/Slide.styles.ts | 2 + components/ImageSlider/Slide.tsx | 45 +++ components/ImageSlider/ThumbnailButton.tsx | 38 +++ components/ImageSlider/index.ts | 1 + components/Storyblok/SbImageSlider.tsx | 46 +++ components/Storyblok/Storyblok.types.ts | 8 + components/StoryblokProvider.tsx | 2 + hooks/useEscape.ts | 16 - package-lock.json | 74 ++++- package.json | 4 +- styles/slick.min.css | 1 + utilities/getIsSbImagePortrait.ts | 12 + 22 files changed, 629 insertions(+), 34 deletions(-) create mode 100644 components/ImageSlider/ImageSlider.styles.ts create mode 100644 components/ImageSlider/ImageSlider.tsx create mode 100644 components/ImageSlider/NextPrevButton.styles.ts create mode 100644 components/ImageSlider/NextPrevButton.tsx create mode 100644 components/ImageSlider/Slide.styles.ts create mode 100644 components/ImageSlider/Slide.tsx create mode 100644 components/ImageSlider/ThumbnailButton.tsx create mode 100644 components/ImageSlider/index.ts create mode 100644 components/Storyblok/SbImageSlider.tsx delete mode 100644 hooks/useEscape.ts create mode 100644 styles/slick.min.css create mode 100644 utilities/getIsSbImagePortrait.ts diff --git a/app/(storyblok)/error.tsx b/app/(storyblok)/error.tsx index cf15943b..d320a24c 100644 --- a/app/(storyblok)/error.tsx +++ b/app/(storyblok)/error.tsx @@ -5,8 +5,8 @@ import { Masthead } from '@/components/Masthead'; import { Container } from '@/components/Container'; export default function Error({error, reset}: { - error: Error & { digest?: string } - reset: () => void + error: Error & { digest?: string }; + reset: VoidFunction; }) { useEffect(() => { // Log the error to an error reporting service @@ -24,4 +24,4 @@ export default function Error({error, reset}: { ); -} \ No newline at end of file +} diff --git a/app/global-error.tsx b/app/global-error.tsx index 7b074bf0..952b79f0 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -5,8 +5,8 @@ import { Masthead } from '@/components/Masthead'; import { Container } from '@/components/Container'; export default function Error({error, reset}: { - error: Error & { digest?: string } - reset: () => void + error: Error & { digest?: string }; + reset: VoidFunction; }) { useEffect(() => { // Log the error to an error reporting service @@ -24,4 +24,4 @@ export default function Error({error, reset}: { ); -} \ No newline at end of file +} diff --git a/app/globals.css b/app/globals.css index b5c61c95..76e7e1d1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.slick-track, .slick-list { + @apply w-full; +} diff --git a/app/layout.tsx b/app/layout.tsx index f8da25ef..075f67dc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import './globals.css'; +import '../styles/slick.min.css'; import 'react-loading-skeleton/dist/skeleton.css'; import { cnb } from 'cnbuilder'; import { Source_Sans_3, Source_Serif_4 } from 'next/font/google'; diff --git a/components/FlexBox/FlexBox.tsx b/components/FlexBox/FlexBox.tsx index 8c314cff..6695d2b0 100644 --- a/components/FlexBox/FlexBox.tsx +++ b/components/FlexBox/FlexBox.tsx @@ -1,10 +1,10 @@ -import React, { ReactNode, HTMLAttributes } from 'react'; +import React, { ReactNode, HTMLAttributes, forwardRef } from 'react'; import { cnb } from 'cnbuilder'; import * as styles from './FlexBox.styles'; import * as types from './FlexBox.types'; type FlexBoxProps = HTMLAttributes & { - as?: types.FlexElementType; + as?: React.ElementType; direction?: types.FlexDirectionType; wrap?: types.FlexWrapType; gap?: boolean; @@ -14,7 +14,7 @@ type FlexBoxProps = HTMLAttributes & { children?: ReactNode; }; -export const FlexBox = ({ +export const FlexBox = forwardRef(({ as: AsComponent = 'div', direction, gap, @@ -25,9 +25,10 @@ export const FlexBox = ({ children, className, ...props -}: FlexBoxProps) => ( +}, ref) => ( {children} -); +)); + diff --git a/components/HeroIcon/HeroIcon.styles.tsx b/components/HeroIcon/HeroIcon.styles.tsx index b3562be8..4127b1a4 100644 --- a/components/HeroIcon/HeroIcon.styles.tsx +++ b/components/HeroIcon/HeroIcon.styles.tsx @@ -5,9 +5,11 @@ import { ArrowDownTrayIcon, ArrowLeftIcon, ArrowRightIcon, + ArrowsPointingOutIcon, ArrowUpRightIcon, Bars3Icon, ChevronDownIcon, + ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, CursorArrowRaysIcon, @@ -34,9 +36,11 @@ export const iconMap = { copy: DocumentDuplicateIcon, check: CheckIcon, 'chevron-down': ChevronDownIcon, + 'chevron-left': ChevronLeftIcon, 'chevron-right': ChevronRightIcon, 'chevron-up': ChevronUpIcon, download: ArrowDownTrayIcon, + expand: ArrowsPointingOutIcon, 'triangle-down': PlayIcon, 'triangle-right': PlayIcon, 'triangle-up': PlayIcon, @@ -80,6 +84,7 @@ export const iconBaseStyle: IconBaseStyleType = { 'triangle-up': 'w-09em scale-x-90 -rotate-90 mt-02em', download: 'w-09em', email: 'w-1em', + expand: 'w-1em -mt-02em', external: 'w-08em stroke-[2.5]', left: 'w-08em', link: 'w-09em -mt-01em', diff --git a/components/ImageSlider/ImageSlider.styles.ts b/components/ImageSlider/ImageSlider.styles.ts new file mode 100644 index 00000000..3ea1fda6 --- /dev/null +++ b/components/ImageSlider/ImageSlider.styles.ts @@ -0,0 +1,42 @@ +import { cnb } from 'cnbuilder'; + +export const root = 'mx-auto w-full sm:w-[calc(100%_-12rem)] md:w-[calc(100%_-17rem)]'; + +export const slider = 'leading-none'; +export const buttonWrapper = 'gap-16 mt-10 sm:mt-0'; +export const buttonBase = 'relative sm:absolute sm:top-[-33cqw] lg:top-[-31cqw]'; +export const nextButton = `${buttonBase} sm:-left-60 md:-left-80`; +export const prevButton = `${buttonBase} sm:-right-60 md:-right-80`; +export const counterExpandWrapper = 'sm:justify-between mt-9'; + +export const pagerWindow = 'rs-mt-0 relative hidden sm:block overflow-hidden'; +export const pagerList = 'list-unstyled *:mb-0 *:leading-[0] gap-10 transition-transform'; +export const thumbButton = (active: boolean, isPortrait: boolean) => cnb( + 'inline-block hocus-visible:opacity-100 hocus-visible:border-b-digital-red-light hocus-visible:-translate-y-9 transition-all border-b-[3px] pt-9 pb-6', + active ? 'opacity-100 border-b-[3px] border-b-digital-red-light -translate-y-9' : 'opacity-70 border-b-transparent', + isPortrait ? 'w-50 md:w-65' : 'w-80 md:w-100', +); +export const expandButton = (isLightText: boolean) => cnb( + 'group hidden sm:inline-block font-semibold leading-none gc-card hocus-visible:underline [transform:translate3d(0,0,0)];', + isLightText ? 'text-digital-red-xlight hocus-visible:text-white' : 'text-digital-red-light hocus-visible:text-gc-black', +); +export const expandIcon = 'inline-block ml-02em group-hocus-visible:scale-110'; +export const skipButton = 'hidden sm:block skiplink focus:!relative left-0 -top-30 break-words type-0 whitespace-normal'; +export const caption = 'rs-mt-0 max-w-prose *:leading-snug *:gc-caption'; + +// Modal styles +export const dialog = 'hidden sm:block relative z-[150]'; +export const srOnly = 'sr-only'; +export const dialogOverlay = 'fixed inset-0 bg-black-true/90 backdrop-blur-lg w-screen'; +export const dialogWrapper = 'fixed inset-0 w-screen overflow-y-auto overscroll-contain overflow-x-hidden'; +export const dialogPanel = 'relative cc flex flex-col w-screen inset-0 break-words justify-start text-white'; +export const modalClose = 'absolute top-20 z-[200] right-0 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 = 'relative w-full'; + +// Modal Slider elements +export const modalSliderWrapper = 'relative mt-90 md:mt-100 mx-auto'; +export const modalSlider = 'relative !flex items-center gap-20 md:gap-30 leading-none'; +export const belowModalSlider = 'relative mt-9'; +export const modalCounter = 'block'; +export const modalCaption = 'rs-mt-0 max-w-prose mx-auto *:leading-snug *:gc-caption'; diff --git a/components/ImageSlider/ImageSlider.tsx b/components/ImageSlider/ImageSlider.tsx new file mode 100644 index 00000000..2c3ae297 --- /dev/null +++ b/components/ImageSlider/ImageSlider.tsx @@ -0,0 +1,297 @@ +import React, { + useCallback, useEffect, useMemo, useState, useRef, +} from 'react'; +import { + Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, +} from '@headlessui/react'; +import Slider from 'react-slick'; +import { useOnClickOutside } from 'usehooks-ts'; +import { Container } from '@/components/Container'; +import { FlexBox } from '@/components/FlexBox'; +import { HeroIcon } from '@/components/HeroIcon'; +import { NextPrevButton } from '@/components/ImageSlider/NextPrevButton'; +import { ThumbnailButton } from '@/components/ImageSlider/ThumbnailButton'; +import { Slide } from '@/components/ImageSlider/Slide'; +import { RichText } from '@/components/RichText'; +import { SrOnlyText, Text } from '@/components/Typography'; +import { type MarginType } from '@/utilities/datasource'; +import { type SbSliderImageType } from '@/components/Storyblok/Storyblok.types'; +import * as styles from './ImageSlider.styles'; + +type ImageSliderProps = React.HTMLAttributes & { + slides: SbSliderImageType[]; + isLightText?: boolean; + ariaLabel?: string; + showExpandLink?: boolean; + marginTop?: MarginType; + marginBottom?: MarginType; +} + +export const ImageSlider = ({ + slides, + isLightText, + ariaLabel, + showExpandLink, + marginTop, + marginBottom, + ...props +}: ImageSliderProps) => { + const [activeSlide, setActiveSlide] = useState(0); + const [pagerOffset, setPagerOffset] = useState(0); + const pagerWindowRef = useRef(null); + const pagerRef = useRef(null); + const sliderRef = useRef(null); + + // Modal states and refs + const [isModalOpen, setIsModalOpen] = useState(false); + const modalSliderRef = useRef(null); + const modalContentRef = useRef(null); + + useOnClickOutside(modalContentRef, () => { + setIsModalOpen(false); + }); + + const clickPrev = useCallback(() => sliderRef.current?.slickPrev(), []); + const clickNext = useCallback(() => sliderRef.current?.slickNext(), []); + + const focusLastThumb = useCallback(() => { + const lastThumb = pagerRef.current?.lastElementChild as HTMLElement; + const button = lastThumb?.querySelector('button') as HTMLButtonElement; + button?.focus(); + }, []); + + const openModal = useCallback(() => setIsModalOpen(true), []); + const closeModal = useCallback(() => { + setIsModalOpen(false); + sliderRef.current?.slickGoTo(activeSlide, true); + }, [activeSlide]); + + // This moves the thumbnail into view when the active slide changes. + const adjustPagerPosition = useCallback( + (targetIndex?: number) => { + if (!pagerRef.current || !pagerWindowRef.current) return; + + const index = targetIndex ?? activeSlide; + const targetItem = pagerRef.current.children[index]; + if (!targetItem) return; + + const windowBox = pagerWindowRef.current.getBoundingClientRect(); + const pagerBox = pagerRef.current.getBoundingClientRect(); + const targetItemBox = targetItem.getBoundingClientRect(); + + const rightGutter = 10; + const currentOffset = pagerBox.left - windowBox.left; + + if (targetItemBox.right > windowBox.right) { + setPagerOffset(currentOffset + (windowBox.right - targetItemBox.right) - rightGutter); + } else if (targetItemBox.left < windowBox.left) { + setPagerOffset(currentOffset + (windowBox.left - targetItemBox.left)); + } + }, [activeSlide], + ); + + useEffect(() => { + adjustPagerPosition(); + }, [activeSlide, adjustPagerPosition]); + + const sliderSettings = useMemo(() => ({ + arrows: false, + accessibility: true, + swipeToSlide: true, + lazyLoad: 'ondemand' as const, + dots: true, + dotsClass: 'relative @container', + customPaging: (i: number) => ( + adjustPagerPosition(i)} + ariaLabel={`Go to slide ${i + 1} ${slides[i]?.alt || ''}`} + /> + ), + afterChange: (i: number) => { + setActiveSlide(i); + }, + // The bottom half of the Slider which includes the counter, expand button, caption and thumbnail nav. + appendDots: (dots: React.ReactNode) => ( +
+ + + + + + + {`Slide ${activeSlide + 1} of ${slides?.length}`} + {showExpandLink && ( + + )} + + + + +
+ ), + }), [ + activeSlide, + adjustPagerPosition, + ariaLabel, + clickNext, + clickPrev, + focusLastThumb, + isLightText, + openModal, + pagerOffset, + showExpandLink, + slides, + ]); + + const modalSliderSettings = useMemo(() => ({ + accessibility: true, + swipeToSlide: true, + lazyLoad: 'ondemand' as const, + nextArrow: ( + + ), + prevArrow: ( + + ), + afterChange: (i: number) => setActiveSlide(i), + initialSlide: activeSlide, + }), [activeSlide]); + + return ( + <> + + + {slides?.map((slide) => ( + + ))} + + {/* Content from appendDots appears here */} + + + + +
+ + +
+ + {`${ariaLabel || 'Photo gallery' } full screen view`} +
+ +
+
+ + {slides?.map((slide) => ( + + ))} + +
+
+ + {`Slide ${activeSlide + 1} of ${slides?.length}`} + +
+
+
+
+
+
+
+
+ + ); +}; diff --git a/components/ImageSlider/NextPrevButton.styles.ts b/components/ImageSlider/NextPrevButton.styles.ts new file mode 100644 index 00000000..0d4d205a --- /dev/null +++ b/components/ImageSlider/NextPrevButton.styles.ts @@ -0,0 +1,11 @@ +import { cnb } from 'cnbuilder'; + +export const root = (isLightText: boolean) => cnb( + 'group flex items-center justify-center size-40 md:size-55 rounded-full border-2 shrink-0 hocus-visible:border-digital-red-light hocus-visible:bg-digital-red-light transition-colors', + isLightText ? 'border-white' : 'border-gc-black', +); + +export const icon = (isLightText: boolean) => cnb( + 'inline-block stroke-[3px] group-hocus-visible:text-white', + isLightText ? 'text-white' : 'text-gc-black', +); diff --git a/components/ImageSlider/NextPrevButton.tsx b/components/ImageSlider/NextPrevButton.tsx new file mode 100644 index 00000000..e8cc80b4 --- /dev/null +++ b/components/ImageSlider/NextPrevButton.tsx @@ -0,0 +1,30 @@ +import { HeroIcon } from '@/components/HeroIcon'; +import { SrOnlyText } from '@/components/Typography'; +import { cnb } from 'cnbuilder'; +import * as styles from './NextPrevButton.styles'; +import React from 'react'; + +type NextPrevButtonProps = React.HTMLAttributes & { + direction: 'next' | 'prev'; + isLightText?: boolean; + onClick?: VoidFunction; +}; + +export const NextPrevButton = ({ + direction = 'next', + isLightText, + onClick, + className, +}: NextPrevButtonProps) => ( + +); diff --git a/components/ImageSlider/Slide.styles.ts b/components/ImageSlider/Slide.styles.ts new file mode 100644 index 00000000..1c5e2958 --- /dev/null +++ b/components/ImageSlider/Slide.styles.ts @@ -0,0 +1,2 @@ +export const root = 'aspect-w-16 aspect-h-9'; +export const image = 'size-full object-contain object-bottom'; diff --git a/components/ImageSlider/Slide.tsx b/components/ImageSlider/Slide.tsx new file mode 100644 index 00000000..e4e320c2 --- /dev/null +++ b/components/ImageSlider/Slide.tsx @@ -0,0 +1,45 @@ +import { getProcessedImage } from '@/utilities/getProcessedImage'; +import * as styles from './Slide.styles'; + +type SlideProps = React.HTMLAttributes & { + imageSrc?: string; + alt?: string; +} + +export const Slide = ({ + imageSrc, + alt, + ...props +}: SlideProps) => { + if (!imageSrc) { + return null; + } + + return ( +
+ + + + + + {alt + +
+ ); +}; diff --git a/components/ImageSlider/ThumbnailButton.tsx b/components/ImageSlider/ThumbnailButton.tsx new file mode 100644 index 00000000..b96f25ab --- /dev/null +++ b/components/ImageSlider/ThumbnailButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { getIsSbImagePortrait } from '@/utilities/getIsSbImagePortrait'; +import { getProcessedImage } from '@/utilities/getProcessedImage'; +import { type SbSliderImageType } from '@/components/Storyblok/Storyblok.types'; +import * as styles from './ImageSlider.styles'; + +type ThumbnailButtonProps = React.HTMLAttributes & { + slide: SbSliderImageType; + isActive: boolean; + ariaLabel: string; +}; + +export const ThumbnailButton = ({ + slide, + isActive, + ariaLabel, + ...props +}: ThumbnailButtonProps) => { + const isPortrait = getIsSbImagePortrait(slide?.image.filename); + + return ( + + ); +}; diff --git a/components/ImageSlider/index.ts b/components/ImageSlider/index.ts new file mode 100644 index 00000000..53220ce9 --- /dev/null +++ b/components/ImageSlider/index.ts @@ -0,0 +1 @@ +export * from './ImageSlider'; diff --git a/components/Storyblok/SbImageSlider.tsx b/components/Storyblok/SbImageSlider.tsx new file mode 100644 index 00000000..9c0d0087 --- /dev/null +++ b/components/Storyblok/SbImageSlider.tsx @@ -0,0 +1,46 @@ +import { storyblokEditable } from '@storyblok/react/rsc'; +import { ImageSlider } from '@/components/ImageSlider'; +import { type MarginType } from '@/utilities/datasource'; +import { type SbSliderImageType } from '@/components/Storyblok/Storyblok.types'; + +type SbImageSliderProps = { + blok: { + _uid: string; + images: SbSliderImageType[]; + isLightText?: boolean; + ariaLabel?: string; + showExpandLink?: boolean; + marginTop?: MarginType; + marginBottom?: MarginType; + isHidden?: boolean; + }; +} + +export const SbImageSlider = ({ + blok: { + images, + ariaLabel, + isLightText, + showExpandLink, + marginTop, + marginBottom, + isHidden, + }, + blok, +}: SbImageSliderProps) => { + if (isHidden) { + return null; + } + + return ( + + ); +}; diff --git a/components/Storyblok/Storyblok.types.ts b/components/Storyblok/Storyblok.types.ts index 3e8c8dc2..07ee9d91 100644 --- a/components/Storyblok/Storyblok.types.ts +++ b/components/Storyblok/Storyblok.types.ts @@ -100,3 +100,11 @@ export type SbImageHotspotType = { isVerticalCard: boolean; descriptionSize: SbImageHotspotDescriptionSizeType; }; + +// Used for Image Slider component +export type SbSliderImageType = { + _uid: string; + image?: SbImageType; + alt?: string; + caption?: StoryblokRichtext; +}; diff --git a/components/StoryblokProvider.tsx b/components/StoryblokProvider.tsx index 26074445..7dd67675 100644 --- a/components/StoryblokProvider.tsx +++ b/components/StoryblokProvider.tsx @@ -20,6 +20,7 @@ import { SbGridAlternating } from '@/components/Storyblok/SbGridAlternating'; import { SbFeatureMasonry } from '@/components/Storyblok/SbFeatureMasonry'; import { SbHomepageMvp } from '@/components/Storyblok/SbHomepageMvp'; import { SbHomepageThemeSection } from '@/components/Storyblok/SbHomepageThemeSection'; +import { SbImageSlider } from '@/components/Storyblok/SbImageSlider'; import { SbInitiativeCard } from '@/components/Storyblok/SbInitiativeCard'; import { SbMainNav } from '@/components/Storyblok/SbMainNav'; import { SbMasthead } from '@/components/Storyblok/SbMasthead'; @@ -66,6 +67,7 @@ export const components = { sbFeatureMasonry: SbFeatureMasonry, sbHomepageMvp: SbHomepageMvp, sbHomepageThemeSection: SbHomepageThemeSection, + sbImageSlider: SbImageSlider, sbInitiativeCard: SbInitiativeCard, sbMainNav: SbMainNav, sbMasthead: SbMasthead, diff --git a/hooks/useEscape.ts b/hooks/useEscape.ts deleted file mode 100644 index 281ef6f9..00000000 --- a/hooks/useEscape.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react'; - -const useEscape = (onEscape: () => void) => { - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.keyCode === 27) onEscape(); - }; - window.addEventListener('keydown', handleEsc); - - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, [onEscape]); -}; - -export default useEscape; diff --git a/package-lock.json b/package-lock.json index 82725cb0..d6799f70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "ood-giving-campaign", "version": "3.5.0", "dependencies": { - "@headlessui/react": "^2.1.10", + "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.1.5", "@netlify/functions": "^2.8.2", "@netlify/plugin-nextjs": "^5.8.0", @@ -21,6 +21,7 @@ "next": "^14.2.15", "react-loading-skeleton": "^3.5.0", "react-player": "^2.16.0", + "react-slick": "^0.30.3", "sa11y": "^4.0.2", "storyblok-rich-text-react-renderer-ts": "^3.2.0", "usehooks-ts": "^3.1.0" @@ -30,6 +31,7 @@ "@types/node": "^22.7.2", "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", + "@types/react-slick": "^0.23.13", "autoprefixer": "^10.4.20", "decanter": "^7.3.0", "eslint": "^8.57.0", @@ -179,9 +181,9 @@ "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==" }, "node_modules/@headlessui/react": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.1.10.tgz", - "integrity": "sha512-6mLa2fjMDAFQi+/R10B+zU3edsUk/MDtENB2zHho0lqKU1uzhAfJLUduWds4nCo8wbl3vULtC5rJfZAQ1yqIng==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", + "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.26.16", @@ -193,8 +195,8 @@ "node": ">=10" }, "peerDependencies": { - "react": "^18", - "react-dom": "^18" + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "node_modules/@heroicons/react": { @@ -899,6 +901,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-slick": { + "version": "0.23.13", + "resolved": "https://registry.npmjs.org/@types/react-slick/-/react-slick-0.23.13.tgz", + "integrity": "sha512-bNZfDhe/L8t5OQzIyhrRhBr/61pfBcWaYJoq6UDqFtv5LMwfg4NsVDD2J8N01JqdAdxLjOt66OZEp6PX+dGs/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", @@ -1748,6 +1760,12 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2126,6 +2144,12 @@ "node": ">=10.13.0" } }, + "node_modules/enquire.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", + "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==", + "license": "MIT" + }, "node_modules/es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -3852,6 +3876,15 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -21013,6 +21046,23 @@ "react": ">=16.6.0" } }, + "node_modules/react-slick": { + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.30.3.tgz", + "integrity": "sha512-B4x0L9GhkEWUMApeHxr/Ezp2NncpGc+5174R02j+zFiWuYboaq98vmxwlpafZfMjZic1bjdIqqmwLDcQY0QaFA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "enquire.js": "^2.1.6", + "json2mq": "^0.2.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -21085,6 +21135,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "devOptional": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -21431,6 +21487,12 @@ "node": ">=10.0.0" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/package.json b/package.json index f8892914..46aa2b14 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "tsc": "tsc" }, "dependencies": { - "@headlessui/react": "^2.1.10", + "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.1.5", "@netlify/functions": "^2.8.2", "@netlify/plugin-nextjs": "^5.8.0", @@ -45,6 +45,7 @@ "next": "^14.2.15", "react-loading-skeleton": "^3.5.0", "react-player": "^2.16.0", + "react-slick": "^0.30.3", "sa11y": "^4.0.2", "storyblok-rich-text-react-renderer-ts": "^3.2.0", "usehooks-ts": "^3.1.0" @@ -58,6 +59,7 @@ "@types/node": "^22.7.2", "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", + "@types/react-slick": "^0.23.13", "autoprefixer": "^10.4.20", "decanter": "^7.3.0", "eslint": "^8.57.0", diff --git a/styles/slick.min.css b/styles/slick.min.css new file mode 100644 index 00000000..05282a3b --- /dev/null +++ b/styles/slick.min.css @@ -0,0 +1 @@ +.slick-slider{position:relative;display:block;box-sizing:border-box;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none;-khtml-user-select:none;-ms-touch-action:pan-y;touch-action:pan-y;-webkit-tap-highlight-color:transparent}.slick-list{position:relative;display:block;overflow:hidden;margin:0;padding:0}.slick-list:focus{outline:0}.slick-list.dragging{cursor:pointer;cursor:hand}.slick-slider .slick-list,.slick-slider .slick-track{-webkit-transform:translate3d(0,0,0);-moz-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.slick-track{position:relative;top:0;left:0;display:block;margin-left:auto;margin-right:auto}.slick-track:after,.slick-track:before{display:table;content:''}.slick-track:after{clear:both}.slick-loading .slick-track{visibility:hidden}.slick-slide{display:none;float:left;height:100%;min-height:1px}[dir=rtl] .slick-slide{float:right}.slick-slide img{display:block}.slick-slide.slick-loading img{display:none}.slick-slide.dragging img{pointer-events:none}.slick-initialized .slick-slide{display:block}.slick-loading .slick-slide{visibility:hidden}.slick-vertical .slick-slide{display:block;height:auto;border:1px solid transparent}.slick-arrow.slick-hidden{display:none} diff --git a/utilities/getIsSbImagePortrait.ts b/utilities/getIsSbImagePortrait.ts new file mode 100644 index 00000000..11ffff43 --- /dev/null +++ b/utilities/getIsSbImagePortrait.ts @@ -0,0 +1,12 @@ +import { getSbImageSize } from '@/utilities/getSbImageSize'; + +/** + * Determines if a Storyblok image is in portrait orientation. + * + * @param imageSrc - The URL of the Storyblok image. + * @returns A boolean indicating whether the image is in portrait orientation. + */ +export const getIsSbImagePortrait = (imageSrc: string) => { + const { width, height } = getSbImageSize(imageSrc); + return height > width; +};