diff --git a/package.json b/package.json index e94d6173..3b296a39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-helsinki-headless-cms", - "version": "1.0.0-alpha285", + "version": "1.0.0-alpha289", "description": "React components for displaying Headless CMS content according to guidelines set by HDS", "main": "cjs/index.js", "module": "index.js", diff --git a/src/common/headlessService/__generated__.ts b/src/common/headlessService/__generated__.ts index 227af663..21d331df 100644 --- a/src/common/headlessService/__generated__.ts +++ b/src/common/headlessService/__generated__.ts @@ -6482,18 +6482,12 @@ export enum PostStatusEnum { DpRewriteRepublish = 'DP_REWRITE_REPUBLISH', /** Objects with the draft status */ Draft = 'DRAFT', - /** Objects with the draft-revision status */ - DraftRevision = 'DRAFT_REVISION', /** Objects with the future status */ Future = 'FUTURE', - /** Objects with the future-revision status */ - FutureRevision = 'FUTURE_REVISION', /** Objects with the inherit status */ Inherit = 'INHERIT', /** Objects with the pending status */ Pending = 'PENDING', - /** Objects with the pending-revision status */ - PendingRevision = 'PENDING_REVISION', /** Objects with the private status */ Private = 'PRIVATE', /** Objects with the publish status */ @@ -10249,6 +10243,8 @@ export type SiteSettings = { __typename?: 'SiteSettings'; /** Attachment ID for logo */ logo?: Maybe; + /** Redirects */ + redirects?: Maybe; /** Identifying name */ siteName?: Maybe; }; @@ -12122,8 +12118,6 @@ export enum UserRoleEnum { /** User role with specific capabilities */ HeadlessCmsViewer = 'HEADLESS_CMS_VIEWER', /** User role with specific capabilities */ - Revisor = 'REVISOR', - /** User role with specific capabilities */ Subscriber = 'SUBSCRIBER', } @@ -13100,7 +13094,19 @@ export type PostFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -13531,7 +13537,19 @@ export type ArticleQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -13995,7 +14013,19 @@ export type PostsQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -14593,7 +14623,19 @@ export type MenuItemFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -14995,7 +15037,19 @@ export type MenuItemFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -15440,7 +15494,19 @@ export type MenuItemFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -15842,7 +15908,19 @@ export type MenuItemFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -16312,7 +16390,19 @@ export type MenuPageFieldsFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -16705,7 +16795,19 @@ export type MenuPageFieldsFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -17198,7 +17300,19 @@ export type MenuQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -17600,7 +17714,19 @@ export type MenuQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -18045,7 +18171,19 @@ export type MenuQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -18447,7 +18585,19 @@ export type MenuQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -18795,6 +18945,20 @@ export type LayoutImageFragment = { } | null; }; +export type LayoutImageGalleryFragment = { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; +}; + export type LayoutStepsFragment = { __typename: 'LayoutSteps'; color?: string | null; @@ -19174,7 +19338,19 @@ export type PageFragment = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -19611,7 +19787,19 @@ export type PageQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -20050,7 +20238,19 @@ export type PageByTemplateQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -20528,7 +20728,19 @@ export type PageChildrenSearchQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -20930,7 +21142,19 @@ export type PageChildrenSearchQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -21398,7 +21622,19 @@ export type PagesQuery = { medium_large?: string | null; } | null; } - | { __typename?: 'LayoutImageGallery' } + | { + __typename: 'LayoutImageGallery'; + gallery?: Array<{ + __typename?: 'GalleryImage'; + caption?: string | null; + description?: string | null; + large?: string | null; + medium?: string | null; + thumbnail?: string | null; + title?: string | null; + medium_large?: string | null; + } | null> | null; + } | { __typename: 'LayoutPages'; title?: string | null; @@ -21852,6 +22088,20 @@ export const LayoutImageFragmentDoc = gql` __typename } `; +export const LayoutImageGalleryFragmentDoc = gql` + fragment LayoutImageGallery on LayoutImageGallery { + gallery { + caption + description + large + medium + thumbnail + title + medium_large + } + __typename + } +`; export const LayoutStepsFragmentDoc = gql` fragment LayoutSteps on LayoutSteps { color @@ -21984,6 +22234,9 @@ export const PostFragmentDoc = gql` ... on LayoutImage { ...LayoutImage } + ... on LayoutImageGallery { + ...LayoutImageGallery + } ... on LayoutSteps { ...LayoutSteps } @@ -22008,6 +22261,7 @@ export const PostFragmentDoc = gql` ${LayoutContentFragmentDoc} ${LayoutCardFragmentDoc} ${LayoutImageFragmentDoc} + ${LayoutImageGalleryFragmentDoc} ${LayoutStepsFragmentDoc} `; export const PageFragmentDoc = gql` @@ -22125,6 +22379,9 @@ export const PageFragmentDoc = gql` ... on LayoutImage { ...LayoutImage } + ... on LayoutImageGallery { + ...LayoutImageGallery + } ... on LayoutSteps { ...LayoutSteps } @@ -22148,6 +22405,7 @@ export const PageFragmentDoc = gql` ${LayoutContentFragmentDoc} ${LayoutCardFragmentDoc} ${LayoutImageFragmentDoc} + ${LayoutImageGalleryFragmentDoc} ${LayoutStepsFragmentDoc} `; export const MenuPageFieldsFragmentDoc = gql` diff --git a/src/common/headlessService/graphql/article.graphql b/src/common/headlessService/graphql/article.graphql index ddb6badd..44e7248e 100644 --- a/src/common/headlessService/graphql/article.graphql +++ b/src/common/headlessService/graphql/article.graphql @@ -128,6 +128,9 @@ fragment Post on Post { ... on LayoutImage { ...LayoutImage } + ... on LayoutImageGallery { + ...LayoutImageGallery + } ... on LayoutSteps { ...LayoutSteps } diff --git a/src/common/headlessService/graphql/modules.graphql b/src/common/headlessService/graphql/modules.graphql index 62d91cb2..f76a2760 100644 --- a/src/common/headlessService/graphql/modules.graphql +++ b/src/common/headlessService/graphql/modules.graphql @@ -248,6 +248,19 @@ fragment LayoutImage on LayoutImage { __typename } +fragment LayoutImageGallery on LayoutImageGallery { + gallery { + caption + description + large + medium + thumbnail + title + medium_large + } + __typename +} + fragment LayoutSteps on LayoutSteps { color description diff --git a/src/common/headlessService/graphql/page.graphql b/src/common/headlessService/graphql/page.graphql index e62a27d5..cc6a85e7 100644 --- a/src/common/headlessService/graphql/page.graphql +++ b/src/common/headlessService/graphql/page.graphql @@ -115,6 +115,9 @@ fragment Page on Page { ... on LayoutImage { ...LayoutImage } + ... on LayoutImageGallery { + ...LayoutImageGallery + } ... on LayoutSteps { ...LayoutSteps } diff --git a/src/common/headlessService/types.ts b/src/common/headlessService/types.ts index 97cabbcb..c5f22d07 100644 --- a/src/common/headlessService/types.ts +++ b/src/common/headlessService/types.ts @@ -37,6 +37,7 @@ export type { LayoutCardFragment as LayoutCard, LayoutCardsFragment as LayoutCards, LayoutImageFragment as LayoutImage, + LayoutImageGalleryFragment as LayoutImageGallery, LayoutStepsFragment as LayoutSteps, EventSearchFragment as EventSearch, EventSelectedFragment as EventSelected, diff --git a/src/common/headlessService/utils.ts b/src/common/headlessService/utils.ts index 5419670e..37bda743 100644 --- a/src/common/headlessService/utils.ts +++ b/src/common/headlessService/utils.ts @@ -15,6 +15,7 @@ import { LayoutCard, LayoutCards, LayoutImage, + LayoutImageGallery, LayoutSteps, LayoutLinkList, LayoutPage, @@ -71,6 +72,15 @@ export function isLayoutImage( ); } +export function isLayoutImageGallery( + module: PageModule | PageSidebarModule, +): module is LayoutImageGallery { + return ( + // eslint-disable-next-line no-underscore-dangle + module.__typename === 'LayoutImageGallery' + ); +} + export function isLayoutSteps( module: PageModule | PageSidebarModule, ): module is LayoutSteps { diff --git a/src/core/imageGallery/ImageGallery.tsx b/src/core/imageGallery/ImageGallery.tsx new file mode 100644 index 00000000..0e432904 --- /dev/null +++ b/src/core/imageGallery/ImageGallery.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { ImageGalleryProvider } from './ImageGalleryProvider'; +import { ImagesGrid } from './ImagesGrid'; +import { Lightbox } from './Lightbox'; +import { ImageItem as ImageItemType } from './types'; + +export type ImageGalleryProps = { + images: ImageItemType[]; + lightboxUid: string; + withBorder?: boolean; + withLightbox?: boolean; + columns?: number; +}; + +export function ImageGallery({ + images, + withBorder = false, + withLightbox = true, + lightboxUid, + columns = 5, +}: ImageGalleryProps) { + return ( + + {withLightbox && } + + + ); +} diff --git a/src/core/imageGallery/ImageGalleryContext.tsx b/src/core/imageGallery/ImageGalleryContext.tsx new file mode 100644 index 00000000..f592267a --- /dev/null +++ b/src/core/imageGallery/ImageGalleryContext.tsx @@ -0,0 +1,25 @@ +import { createContext, Dispatch, SetStateAction } from 'react'; + +export interface ImageGalleryContextProps { + imageIndex: number; + setImageIndex: Dispatch>; + selectedImageIndex: number; + setSelectedImageIndex: Dispatch>; + isLightboxVisible: boolean; + setIsLightboxVisible: Dispatch>; + toggleLightbox: () => void; +} + +export const ImageGalleryContext = createContext({ + imageIndex: 0, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setImageIndex: () => {}, + selectedImageIndex: -1, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setSelectedImageIndex: () => {}, + isLightboxVisible: false, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setIsLightboxVisible: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + toggleLightbox: () => {}, +}); diff --git a/src/core/imageGallery/ImageGalleryProvider.tsx b/src/core/imageGallery/ImageGalleryProvider.tsx new file mode 100644 index 00000000..4c31b889 --- /dev/null +++ b/src/core/imageGallery/ImageGalleryProvider.tsx @@ -0,0 +1,36 @@ +import React, { ReactNode, useCallback, useState } from 'react'; + +import { ImageGalleryContext } from './ImageGalleryContext'; + +interface ImageGalleryProviderProps { + children: ReactNode; +} + +export function ImageGalleryProvider({ children }: ImageGalleryProviderProps) { + const [imageIndex, setImageIndex] = useState(0); + const [selectedImageIndex, setSelectedImageIndex] = useState(-1); + const [isLightboxVisible, setIsLightboxVisible] = useState(false); + + const toggleLightbox = useCallback(() => { + setIsLightboxVisible((prev) => !prev); + }, [setIsLightboxVisible]); + + const config = React.useMemo( + () => ({ + imageIndex, + setImageIndex, + selectedImageIndex, + setSelectedImageIndex, + isLightboxVisible, + setIsLightboxVisible, + toggleLightbox, + }), + [imageIndex, isLightboxVisible, selectedImageIndex, toggleLightbox], + ); + + return ( + + {children} + + ); +} diff --git a/src/core/imageGallery/ImageItem.tsx b/src/core/imageGallery/ImageItem.tsx new file mode 100644 index 00000000..e9aae059 --- /dev/null +++ b/src/core/imageGallery/ImageItem.tsx @@ -0,0 +1,91 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ +// eslint-disable-next-line import/no-extraneous-dependencies +import classNames from 'classnames'; +import React from 'react'; + +import { ImageItem as ImageItemType } from './types'; +import styles from './imageGallery.module.scss'; +import useImageGalleryContext from './useImageGalleryContext'; + +interface ImageItemProps { + image: ImageItemType; + imageId: number; + lightboxUid: string; + withBorder: boolean; + withLightbox: boolean; +} + +export function ImageItem({ + image, + imageId, + lightboxUid, + withLightbox, + withBorder, +}: ImageItemProps) { + const { + isLightboxVisible, + setIsLightboxVisible, + setImageIndex, + setSelectedImageIndex, + toggleLightbox, + } = useImageGalleryContext(); + + const handleImageCardClick = ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + if (withLightbox) { + toggleLightbox(); + } + }; + + const handleEnterKeyPress = (event: React.KeyboardEvent) => { + if (withLightbox && event.key === 'Enter') { + setIsLightboxVisible(true); + } + }; + + const handleImageCardFocus = (index: number) => { + setImageIndex(index); + // selected card index before opening the lightbox + setSelectedImageIndex(index); + }; + + const imageTitle = image.title || image.photographer; + + return ( +
+
+
handleImageCardFocus(imageId)} + onKeyDown={handleEnterKeyPress} + /> +
+
+ {image.photographer} +
+
+ ); +} diff --git a/src/core/imageGallery/ImagesGrid.tsx b/src/core/imageGallery/ImagesGrid.tsx new file mode 100644 index 00000000..396cab6a --- /dev/null +++ b/src/core/imageGallery/ImagesGrid.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useRef } from 'react'; + +import useImageGalleryContext from './useImageGalleryContext'; +import Grid from '../../common/components/grid/Grid'; +import { ImageItem } from './ImageItem'; +import { ImageItem as ImageItemType } from './types'; + +interface ImagesGridProps { + images: ImageItemType[]; + columns: number; + lightboxUid: string; + withBorder: boolean; + withLightbox: boolean; +} + +export function ImagesGrid({ + images, + columns, + lightboxUid, + withLightbox, + withBorder, +}: ImagesGridProps) { + const { isLightboxVisible, selectedImageIndex } = useImageGalleryContext(); + + const gridContainerRef = useRef(null); + + useEffect(() => { + if (!isLightboxVisible && selectedImageIndex !== -1) { + const gridContainer = gridContainerRef.current; + const selectedCard = gridContainer?.querySelector( + `[id="${lightboxUid}-card-${selectedImageIndex}"]`, + ); + if (selectedCard) { + selectedCard?.focus(); + } + } + }, [isLightboxVisible, selectedImageIndex, lightboxUid]); + + return ( +
+ + {images.map((image, i) => ( + + ))} + +
+ ); +} diff --git a/src/core/imageGallery/Lightbox.tsx b/src/core/imageGallery/Lightbox.tsx new file mode 100644 index 00000000..f899e544 --- /dev/null +++ b/src/core/imageGallery/Lightbox.tsx @@ -0,0 +1,188 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ +// eslint-disable-next-line import/no-extraneous-dependencies +import classNames from 'classnames'; +import ReactDOM from 'react-dom'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { Button, IconAngleLeft, IconAngleRight } from 'hds-react'; + +import { ImageItem } from './types'; +import styles from './imageGallery.module.scss'; +import { useConfig } from '../configProvider/useConfig'; +import useImageGalleryContext from './useImageGalleryContext'; + +interface LightboxProps { + images: ImageItem[]; + lightboxUid: string; +} + +interface CloseButtonProps { + lightboxUid: string; +} + +interface ActionsProps { + images: ImageItem[]; +} + +export function Lightbox({ images, lightboxUid }: LightboxProps) { + const lightboxRef = useRef(null); + const barrierRef = useRef(null); + + const { isLightboxVisible, imageIndex, toggleLightbox } = + useImageGalleryContext(); + + const handleEscapeKeyPress = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + toggleLightbox(); + } + }, + [toggleLightbox], + ); + + useEffect(() => { + const lightbox = lightboxRef.current; + if (!lightbox) return undefined; + + const focusableElements = lightbox.querySelectorAll( + 'button, [tabindex]:not([tabindex="-1"])', + ); + const firstElement = focusableElements ? focusableElements[0] : null; + const lastElement = focusableElements + ? focusableElements[focusableElements.length - 1] + : null; + + const handleTabKeyPress = (event: React.KeyboardEvent) => { + if (focusableElements && event.key === 'Tab') { + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + focusableElements[0].focus(); + } + } + }; + + if (isLightboxVisible) { + barrierRef.current?.focus(); + lightbox.addEventListener('keydown', handleTabKeyPress); + lightbox.addEventListener('keydown', handleEscapeKeyPress); + } + + return () => { + lightbox?.removeEventListener('keydown', handleTabKeyPress); + lightbox?.removeEventListener('keydown', handleEscapeKeyPress); + }; + }, [isLightboxVisible, handleEscapeKeyPress]); + + const imageTitle = + images[imageIndex].title || images[imageIndex].photographer; + const imagePhotogrpher = images[imageIndex].photographer; + const imageUrl = images[imageIndex].url; + + const renderLightboxComponent = (): JSX.Element => ( + + ); + + return isLightboxVisible + ? ReactDOM.createPortal(renderLightboxComponent(), document.body) + : null; +} + +function Actions({ images }: ActionsProps) { + const { imageIndex, setImageIndex } = useImageGalleryContext(); + const handleNextClick = () => { + setImageIndex((prev) => (imageIndex === images.length - 1 ? 0 : prev + 1)); + }; + + const handlePreviousClick = () => { + setImageIndex((prev) => (imageIndex === 0 ? images.length - 1 : prev - 1)); + }; + + const { + copy: { previous, next }, + } = useConfig(); + return ( +
+ + +
+ ); +} + +function CloseButton({ lightboxUid }: CloseButtonProps) { + const { toggleLightbox } = useImageGalleryContext(); + + const { + copy: { closeButtonLabelText }, + } = useConfig(); + return ( + + ); +} + +Lightbox.Actions = Actions; +Lightbox.CloseButton = CloseButton; diff --git a/src/core/imageGallery/imageGallery.module.scss b/src/core/imageGallery/imageGallery.module.scss new file mode 100644 index 00000000..8d14ce1a --- /dev/null +++ b/src/core/imageGallery/imageGallery.module.scss @@ -0,0 +1,145 @@ +.imageCardWrapper { + position: relative; + width: 100%; + margin: 0 !important; + overflow: hidden; + + .imageWrapper { + aspect-ratio: 3/2; + width: 100%; + background-repeat: no-repeat; + background-position: 50%; + background-size: cover; + box-sizing: border-box; + border: 2px solid transparent; + + &.withLightbox:focus { + outline: 2px solid var(--color-black); + border: 2px solid var(--color-black); + } + + &.withBorder { + border: 2px solid var(--color-black-90); + } + } +} + +.photographer { + margin: var(--spacing-xs) 0; + word-break: break-all; + &.withMargin { + margin: var(--spacing-xs); + } +} + +.link { + width: 100%; + height: 100%; + display: inline-block; + position: absolute; + top: 0; + left: 0; +} + +.lightbox { + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.6); + position: fixed; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 10; + + & .lightboxContent { + display: flex; + justify-content: center; + align-items: center; + display: flex; + position: absolute; + max-height: 90%; + max-width: 100%; + height: 100%; + margin: var(--spacing-m); + & .inner { + display: flex; + flex-direction: column; + position: relative; + width: 100%; + background: white; + max-height: 100%; + img { + width: 100%; + } + } + } +} + +.closeButton { + color: var(--color-white); + position: absolute; + top: 0; + right: 0; + -webkit-transform: translateY(-100%); + transform: translateY(-100%); + background: 0 0; + border: none; + cursor: pointer; + padding: 0; + + svg { + background-color: var(--color-white); + fill: none; + display: block; + -webkit-transition: 0.3s; + transition: 0.3s; + width: calc(1.5 * 1em); + mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg role='img' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E %3Cg fill='none' fill-rule='evenodd'%3E %3Crect width='24' height='24'/%3E %3Cpolygon fill='currentColor' points='18 7.5 13.5 12 18 16.5 16.5 18 12 13.5 7.5 18 6 16.5 10.5 12 6 7.5 7.5 6 12 10.5 16.5 6'/%3E %3C/g%3E %3C/svg%3E"); + } +} + +.screenReaderText { + border: 0; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + height: 1px; + margin: 0px -1px -1px -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + word-wrap: normal !important; +} + +.actionsWrapper { + display: flex; + gap: 8px; + justify-content: space-between; + padding: var(--spacing-xs); + + button { + &:focus { + outline-color: var(--color-black); + } + + & > div, + & > span { + margin: 0 !important; + padding: 0 !important; + } + } +} + +.imageItemWrapper { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + + &.withLightbox { + cursor: pointer; + } +} diff --git a/src/core/imageGallery/types.ts b/src/core/imageGallery/types.ts new file mode 100644 index 00000000..1cefb505 --- /dev/null +++ b/src/core/imageGallery/types.ts @@ -0,0 +1,6 @@ +export type ImageItem = { + url: string; + previewUrl: string; + photographer: string; + title?: string; +}; diff --git a/src/core/imageGallery/useImageGalleryContext.ts b/src/core/imageGallery/useImageGalleryContext.ts new file mode 100644 index 00000000..e81a0c99 --- /dev/null +++ b/src/core/imageGallery/useImageGalleryContext.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +import { ImageGalleryContext } from './ImageGalleryContext'; + +export default function useImageGalleryContext() { + const context = React.useContext(ImageGalleryContext); + if (!context) { + throw new Error( + `Image gallery components cannot be used outside the ImageGalleryProvider`, + ); + } + return context; +} diff --git a/src/core/pageContent/PageContent.tsx b/src/core/pageContent/PageContent.tsx index b569a86c..588f608e 100644 --- a/src/core/pageContent/PageContent.tsx +++ b/src/core/pageContent/PageContent.tsx @@ -27,6 +27,7 @@ import { isLayoutCards, isLayoutContent, isLayoutImage, + isLayoutImageGallery, isLayoutSteps, isLocationsSelectionCollection, isPageType, @@ -34,7 +35,7 @@ import { import { ContentModule } from '../pageModules/ContentModule/ContentModule'; import { CardModule } from '../pageModules/CardModule/CardModule'; import { CardsModule } from '../pageModules/CardsModule/CardsModule'; -import { ImageModule } from '../pageModules/ImageModule/ImageModule'; +import { ImageGalleryModule } from '../pageModules/ImageGalleryModule/ImageGalleryModule'; import { StepsModule } from '../pageModules/StepsModule/StepsModule'; import createHashKey from '../utils/createHashKey'; import { MAIN_CONTENT_ID } from '../../common/constants'; @@ -72,7 +73,37 @@ export const defaultContentModules = ( } else if (isLayoutCards(module)) { contentModules.push(); } else if (isLayoutImage(module)) { - contentModules.push(); + contentModules.push( + , + ); + } else if (isLayoutImageGallery(module)) { + contentModules.push( + ({ + url: image.medium_large, + previewUrl: image.medium, + photographer: image.caption, + title: image.title, + }))} + key={uniqueKey} + withLightbox + lightboxUid={`lightbox-${index}`} + columns={3} + />, + ); } else if (isLayoutSteps(module)) { contentModules.push( ; + +const Template: StoryFn = (args) => ( + +
+ +
+
+); + +export const ImageGalleryModuleDefault = { + render: Template, +}; diff --git a/src/core/pageModules/ImageGalleryModule/ImageGalleryModule.tsx b/src/core/pageModules/ImageGalleryModule/ImageGalleryModule.tsx new file mode 100644 index 00000000..636df01d --- /dev/null +++ b/src/core/pageModules/ImageGalleryModule/ImageGalleryModule.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { + ImageGallery, + ImageGalleryProps, +} from '../../imageGallery/ImageGallery'; +import styles from '../pageModules.module.scss'; + +export function ImageGalleryModule(props: ImageGalleryProps) { + return ( +
+ +
+ ); +} diff --git a/src/core/pageModules/ImageModule/ImageModule.tsx b/src/core/pageModules/ImageModule/ImageModule.tsx deleted file mode 100644 index 8542d771..00000000 --- a/src/core/pageModules/ImageModule/ImageModule.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -import styles from '../pageModules.module.scss'; - -export type ImageModuleProps = { - // todo: props -}; - -// todo: implement module -export function ImageModule() { - return
Image
; -}