diff --git a/packages/design-system/lib/components/ImageCounter/ImageCounter.stories.tsx b/packages/design-system/lib/components/ImageCounter/ImageCounter.stories.tsx new file mode 100644 index 000000000..c646a023e --- /dev/null +++ b/packages/design-system/lib/components/ImageCounter/ImageCounter.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { ImageCounter } from "./index" + +const meta: Meta = { + title: "Core Components/ImageCounter", + component: ImageCounter, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +export const Large: Story = { + args: { + size: "Large", + leadingIcon: false, + number: "12/36", + }, +} + +export const Small: Story = { + args: { + size: "Small", + leadingIcon: false, + number: "12/36", + }, +} + +export const LargeWithLeadingIcon: Story = { + args: { + size: "Large", + leadingIcon: true, + number: "10", + }, +} +export const SmallWithLeadingIcon: Story = { + args: { + size: "Small", + leadingIcon: true, + number: 10, + }, +} diff --git a/packages/design-system/lib/components/ImageCounter/imageCounter.module.css b/packages/design-system/lib/components/ImageCounter/imageCounter.module.css new file mode 100644 index 000000000..a0e635f72 --- /dev/null +++ b/packages/design-system/lib/components/ImageCounter/imageCounter.module.css @@ -0,0 +1,19 @@ +.imageCounter { + background-color: var(--Overlay-90); + border-radius: var(--Corner-radius-sm); + color: var(--Text-Inverted); + justify-content: center; + display: inline-flex; + align-items: center; + gap: var(--Space-x025); +} + +.small { + padding: 0 var(--Space-x05); + height: 26px; +} + +.large { + padding: 0 var(--Space-x1); + height: 32px; +} diff --git a/packages/design-system/lib/components/ImageCounter/index.tsx b/packages/design-system/lib/components/ImageCounter/index.tsx new file mode 100644 index 000000000..daec62655 --- /dev/null +++ b/packages/design-system/lib/components/ImageCounter/index.tsx @@ -0,0 +1,29 @@ +import { VariantProps } from "class-variance-authority" +import { Typography } from "../Typography" +import { variants } from "./variants" +import { MaterialIcon } from "../Icons/MaterialIcon" + +interface ImageCounterProps extends VariantProps { + number: number | string + leadingIcon?: boolean + className?: string +} + +export function ImageCounter({ + number, + size, + leadingIcon = false, + className, +}: ImageCounterProps) { + const classNames = variants({ className, size }) + return ( + + + {leadingIcon && ( + + )} + {number} + + + ) +} diff --git a/packages/design-system/lib/components/ImageCounter/variants.ts b/packages/design-system/lib/components/ImageCounter/variants.ts new file mode 100644 index 000000000..270507d05 --- /dev/null +++ b/packages/design-system/lib/components/ImageCounter/variants.ts @@ -0,0 +1,17 @@ +import { cva } from "class-variance-authority" + +import styles from "./imageCounter.module.css" + +export const config = { + variants: { + size: { + Large: styles.large, + Small: styles.small, + }, + }, + defaultVariants: { + size: "Large", + }, +} as const + +export const variants = cva(styles.imageCounter, config) diff --git a/packages/design-system/lib/components/ImageGallery/imageGallery.module.css b/packages/design-system/lib/components/ImageGallery/imageGallery.module.css index 674466f5c..1a491fbc1 100644 --- a/packages/design-system/lib/components/ImageGallery/imageGallery.module.css +++ b/packages/design-system/lib/components/ImageGallery/imageGallery.module.css @@ -9,14 +9,6 @@ position: absolute; bottom: var(--Space-x2); right: var(--Space-x2); - background-color: var(--Overlay-90); - padding: var(--Space-x025) var(--Space-x05); - border-radius: var(--Corner-radius-sm); - display: flex; - align-items: center; - justify-content: center; - gap: var(--Space-x025); - color: var(--Text-Inverted); } .imageCountBottom { diff --git a/packages/design-system/lib/components/ImageGallery/index.tsx b/packages/design-system/lib/components/ImageGallery/index.tsx index ccad2da50..1cdcab59e 100644 --- a/packages/design-system/lib/components/ImageGallery/index.tsx +++ b/packages/design-system/lib/components/ImageGallery/index.tsx @@ -11,6 +11,8 @@ import Lightbox from "../Lightbox" import { Typography } from "../Typography" import styles from "./imageGallery.module.css" +import { ImageCounter } from "../ImageCounter" +import { cx } from "class-variance-authority" export interface GalleryImage { src: string @@ -65,18 +67,16 @@ function ImageGallery({ onError={() => setImageError(true)} {...imageProps} /> - - - - {images.length} - - + + setIsOpen(true)} diff --git a/packages/design-system/lib/components/Lightbox/FullView/fullView.module.css b/packages/design-system/lib/components/Lightbox/FullView/fullView.module.css index 62f95f5c9..c7840f9c0 100644 --- a/packages/design-system/lib/components/Lightbox/FullView/fullView.module.css +++ b/packages/design-system/lib/components/Lightbox/FullView/fullView.module.css @@ -26,13 +26,6 @@ width: 100%; } -.imageCount { - background-color: var(--Overlay-90); - padding: var(--Space-x025) var(--Space-x05); - border-radius: var(--Corner-radius-sm); - color: var(--Text-Inverted); -} - .content { position: relative; width: 100%; diff --git a/packages/design-system/lib/components/Lightbox/FullView/index.tsx b/packages/design-system/lib/components/Lightbox/FullView/index.tsx index 2589c4cd3..0d191e7c3 100644 --- a/packages/design-system/lib/components/Lightbox/FullView/index.tsx +++ b/packages/design-system/lib/components/Lightbox/FullView/index.tsx @@ -1,7 +1,7 @@ "use client" import { AnimatePresence, motion } from "motion/react" -import { useEffect, useState } from "react" +import { useState } from "react" import { useIntl } from "react-intl" import Image from "../../Image" @@ -11,6 +11,9 @@ import { Typography } from "../../Typography" import { LightboxImage } from "../index" import styles from "./fullView.module.css" +import { cx } from "class-variance-authority" +import { ImageCounter } from "../../ImageCounter" +import { animationVariants, useKeyboardNavigation } from "../util" type FullViewProps = { image: LightboxImage @@ -22,7 +25,7 @@ type FullViewProps = { hideLabel?: boolean } -export default function FullView({ +export function FullView({ image, onClose, onNext, @@ -32,56 +35,28 @@ export default function FullView({ hideLabel, }: FullViewProps) { const intl = useIntl() - const [animateLeft, setAnimateLeft] = useState(true) + const [slideDirection, setSlideDirection] = useState<"left" | "right">("left") function handleSwipe(offset: number) { if (offset > 30) { - setAnimateLeft(false) + setSlideDirection("right") onPrev() } if (offset < -30) { - setAnimateLeft(true) + setSlideDirection("left") onNext() } } function handleNext() { - setAnimateLeft(true) + setSlideDirection("left") onNext() } - function handlePrev() { - setAnimateLeft(false) + setSlideDirection("right") onPrev() } - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "ArrowLeft") { - handlePrev() - } else if (e.key === "ArrowRight") { - handleNext() - } - } - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown) - - return () => { - window.removeEventListener("keydown", handleKeyDown) - } - }) - - const variants = { - initial: (animateLeft: boolean) => ({ - opacity: 0, - x: animateLeft ? 300 : -300, - }), - animate: { opacity: 1, x: 0 }, - exit: (animateLeft: boolean) => ({ - opacity: 0, - x: animateLeft ? -300 : 300, - }), - } as const + useKeyboardNavigation(handlePrev, handleNext) return (
@@ -96,19 +71,14 @@ export default function FullView({ iconName="close" />
- - - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} - {`${currentIndex + 1} / ${totalImages}`} - - +
- +
diff --git a/packages/design-system/lib/components/Lightbox/Gallery/ThumnailImage.tsx b/packages/design-system/lib/components/Lightbox/Gallery/ThumnailImage.tsx new file mode 100644 index 000000000..991da9ed9 --- /dev/null +++ b/packages/design-system/lib/components/Lightbox/Gallery/ThumnailImage.tsx @@ -0,0 +1,57 @@ +"use client" +import { motion } from "motion/react" + +import Image from "../../Image" +import { LightboxImage } from ".." +import styles from "./gallery.module.css" +import { useIntl } from "react-intl" +import { cx } from "class-variance-authority" +import { Button as ButtonRAC } from "react-aria-components" +import { memo } from "react" + +interface ThumbnailImage { + image: LightboxImage + index: number + onSelect: (image: LightboxImage) => void + isMainImage?: boolean + className?: string +} +export const ThumbnailImage = memo(function ThumbnailImage({ + image, + index, + onSelect, + isMainImage = false, + className, +}: ThumbnailImage) { + const intl = useIntl() + return ( + + onSelect(image)} + aria-label={intl.formatMessage({ + id: "lightbox.openImage", + defaultMessage: "Open image", + })} + > + {image.alt} + + + ) +}) diff --git a/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css b/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css index 013d75ba7..d94437f45 100644 --- a/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css +++ b/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css @@ -19,6 +19,13 @@ height: 242px; } +.thumbnail { + grid-column: 2 / span 5; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--Space-x15); +} + .fullWidthImage { grid-column: 1 / -1; height: 240px; @@ -34,9 +41,11 @@ cursor: pointer; overflow: hidden; z-index: 0; + border-radius: var(--Corner-radius-sm); &:focus-visible { - outline-offset: -2px; /* Adjust the outline offset as wrappers uses overflow-hidden */ + outline: 2px solid var(--Border-Interactive-Focus); + outline-offset: 2px; /* Adjust the outline offset as wrappers uses overflow-hidden */ } } @@ -44,7 +53,21 @@ transition: opacity 0.3s ease-in-out; object-fit: cover; z-index: -1; - border-radius: var(--Corner-radius-md); +} + +.notActiveImage { + opacity: 0.5; +} + +.activeImage { + border: 2px solid var(--Border-Interactive-Active); +} + +.imageCounter { + position: absolute; + bottom: var(--Space-x1); + left: 50%; + transform: translateX(-50%); } @media screen and (max-width: 767px) { @@ -81,7 +104,7 @@ .desktopGallery { display: grid; - grid-template-rows: 28px 1fr 125px; + grid-template-rows: 28px 1fr 130px; row-gap: var(--Space-x15); background-color: var(--Background-Primary); height: 100%; @@ -117,7 +140,7 @@ .mainImage { object-fit: contain; - border-radius: var(--Corner-radius-Medium); + border-radius: var(--Corner-radius-sm); /* Override Next.js Image styles, we can't set width/height on the image as we don't know the aspect ratio of the image */ width: auto !important; height: auto !important; @@ -129,10 +152,9 @@ .desktopThumbnailGrid { display: grid; - grid-template-columns: repeat(5, 1fr); - gap: var(--Space-x1); + grid-template-columns: 0.2fr repeat(5, 1fr) 0.2fr; + gap: var(--Space-x15); max-height: 7.8125rem; - overflow: hidden; } .thumbnailContainer { diff --git a/packages/design-system/lib/components/Lightbox/Gallery/index.tsx b/packages/design-system/lib/components/Lightbox/Gallery/index.tsx index 2b688fd56..8ff208d2e 100644 --- a/packages/design-system/lib/components/Lightbox/Gallery/index.tsx +++ b/packages/design-system/lib/components/Lightbox/Gallery/index.tsx @@ -1,6 +1,6 @@ "use client" import { AnimatePresence, motion } from "motion/react" -import { useEffect, useState } from "react" +import { useMemo, useState } from "react" import { Button as ButtonRAC } from "react-aria-components" import { useIntl } from "react-intl" @@ -13,8 +13,12 @@ import { cx } from "class-variance-authority" import { useMediaQuery } from "usehooks-ts" import { LightboxImage } from ".." import styles from "./gallery.module.css" +import { ImageCounter } from "../../ImageCounter" +import { animationVariants, useKeyboardNavigation } from "../util" +import { ThumbnailImage } from "./ThumnailImage" +import { useThumbnail } from "./useThumbNail" -type GalleryProps = { +interface GalleryProps { images: LightboxImage[] onClose: () => void onSelectImage: (image: LightboxImage) => void @@ -23,7 +27,7 @@ type GalleryProps = { hideLabel?: boolean } -export default function Gallery({ +export function Gallery({ images, onClose, onSelectImage, @@ -32,59 +36,34 @@ export default function Gallery({ hideLabel, }: GalleryProps) { const intl = useIntl() - const [animateLeft, setAnimateLeft] = useState(true) - const mainImage = selectedImage || images[0] - const mainImageIndex = images.findIndex((img) => img === mainImage) + const [slideDirection, setSlideDirection] = useState<"left" | "right">("left") const isMobile = useMediaQuery("(max-width: 767px)") - function getThumbImages() { - const thumbs = [] - for (let i = 1; i <= Math.min(5, images.length); i++) { - const index = (mainImageIndex + i) % images.length - thumbs.push(images[index]) - } - return thumbs - } + const mainImage = selectedImage || images[0] + const mainImageIndex = useMemo( + () => images.findIndex((img) => img === mainImage), + [images, mainImage] + ) + + const thumbnail = useThumbnail({ + images: images, + mainImageIdx: mainImageIndex, + }) function handleNext() { - setAnimateLeft(true) + setSlideDirection("left") const nextIndex = (mainImageIndex + 1) % images.length onSelectImage(images[nextIndex]) } - function handlePrev() { - setAnimateLeft(false) + setSlideDirection("right") const prevIndex = (mainImageIndex - 1 + images.length) % images.length onSelectImage(images[prevIndex]) } + useKeyboardNavigation(handlePrev, handleNext) - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "ArrowLeft") { - handlePrev() - } else if (e.key === "ArrowRight") { - handleNext() - } - } - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown) - - return () => { - window.removeEventListener("keydown", handleKeyDown) - } - }) - - const variants = { - initial: (animateLeft: boolean) => ({ - opacity: 0, - x: animateLeft ? 300 : -300, - }), - animate: { opacity: 1, x: 0 }, - exit: (animateLeft: boolean) => ({ - opacity: 0, - x: animateLeft ? -300 : 300, - }), - } as const + const hasPrevImage = thumbnail.previous !== undefined + const hasNextImage = thumbnail.next !== undefined return (
@@ -110,12 +89,12 @@ export default function Gallery({

- + { + if (e.key === 'Enter' || e.key === ' ') { + onImageClick() + } + }} /> @@ -157,36 +141,37 @@ export default function Gallery({ })} iconName="arrow_forward" /> +
- {getThumbImages().map((image, index) => ( - - onSelectImage(image)} - aria-label={intl.formatMessage({ - id: "lightbox.openImage", - defaultMessage: "Open image", - })} - > - {image.alt} - - - ))} + {hasPrevImage && ( + + )} + + {thumbnail.images.map((image, index) => ( + + ))} + + {hasNextImage && ( + + )}
@@ -196,7 +181,9 @@ export default function Gallery({ {images.map((image, index) => ( { + if (mainImageIdx < THUMBNAIL_OFFSET) { + return { + images: images.slice(0, THUMBNAIL_WINDOW), + next: images[THUMBNAIL_WINDOW + 1], + } + } + if (mainImageIdx >= images.length - THUMBNAIL_OFFSET) { + return { + previous: images[images.length - THUMBNAIL_WINDOW - 1], + images: images.slice(images.length - THUMBNAIL_WINDOW, images.length), + } + } + return { + previous: images[mainImageIdx - THUMBNAIL_OFFSET], + images: images.slice( + mainImageIdx - THUMBNAIL_OFFSET + 1, + mainImageIdx + THUMBNAIL_OFFSET + ), + next: images[mainImageIdx + THUMBNAIL_OFFSET], + } + }, [images, mainImageIdx]) +} diff --git a/packages/design-system/lib/components/Lightbox/index.tsx b/packages/design-system/lib/components/Lightbox/index.tsx index 93f31cca5..b8bcc832a 100644 --- a/packages/design-system/lib/components/Lightbox/index.tsx +++ b/packages/design-system/lib/components/Lightbox/index.tsx @@ -5,10 +5,11 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components" import usePopStateHandler from "@scandic-hotels/common/hooks/usePopStateHandler" -import FullView from "./FullView" -import Gallery from "./Gallery" +import { FullView } from "./FullView" +import { Gallery } from "./Gallery" import styles from "./lightbox.module.css" +import { cx } from "class-variance-authority" export type LightboxImage = { src: string diff --git a/packages/design-system/lib/components/Lightbox/util.tsx b/packages/design-system/lib/components/Lightbox/util.tsx new file mode 100644 index 000000000..a20158d89 --- /dev/null +++ b/packages/design-system/lib/components/Lightbox/util.tsx @@ -0,0 +1,30 @@ +import { useCallback, useEffect } from 'react' + +const ANIMATION_OFFSET = 300 + +export const animationVariants = { + initial: (animateLeft: boolean) => ({ + opacity: 0, + x: animateLeft ? ANIMATION_OFFSET : -ANIMATION_OFFSET, + }), + animate: { opacity: 1, x: 0 }, + exit: (animateLeft: boolean) => ({ + opacity: 0, + x: animateLeft ? -ANIMATION_OFFSET : ANIMATION_OFFSET, + }), +} as const + +export function useKeyboardNavigation(onPrev: () => void, onNext: () => void) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') onPrev() + if (e.key === 'ArrowRight') onNext() + }, + [onPrev, onNext] + ) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) +}