diff --git a/src/fullscreen-image-carousel/fullscreen-image-carousel.style.tsx b/src/fullscreen-image-carousel/fullscreen-image-carousel.style.tsx index a3f985847..fc0dfbc72 100644 --- a/src/fullscreen-image-carousel/fullscreen-image-carousel.style.tsx +++ b/src/fullscreen-image-carousel/fullscreen-image-carousel.style.tsx @@ -2,7 +2,15 @@ import styled, { css } from "styled-components"; import { ClickableIcon } from "../shared/clickable-icon"; import { ImagePlaceholder } from "../shared/image-placeholder"; import { InsetStyleProps } from "../shared/types"; -import { Border, Colour, MediaQuery, Radius, Shadow, Spacing } from "../theme"; +import { + Border, + Colour, + Font, + MediaQuery, + Radius, + Shadow, + Spacing, +} from "../theme"; import { Typography } from "../typography"; import { StatefulImage } from "./stateful-image"; @@ -17,6 +25,14 @@ interface ThumbnailItemStyleProps { $active?: boolean; } +interface TopActionButtonsStyleProps extends InsetStyleProps { + $hasFileInfo?: boolean | undefined; +} + +interface FileInfoTextWrapperStyleProps { + $centerContent?: boolean | undefined; +} + // ============================================================================= // STYLING // ============================================================================= @@ -44,23 +60,61 @@ const IconButton = styled(ClickableIcon)` } `; -export const TopActionButtons = styled.div` - position: absolute; - top: ${(props) => - css`calc(${Spacing["spacing-48"]} + ${props.$insetTop || 0}px)`}; - right: ${(props) => - css`calc(${Spacing["spacing-48"]} + ${props.$insetRight || 0}px)`}; - z-index: 5; +export const TopActionButtons = styled.div` + order: -1; display: flex; align-items: center; + justify-content: flex-end; gap: ${Spacing["spacing-16"]}; - ${MediaQuery.MaxWidth.sm} { - top: ${(props) => - css`calc(${Spacing["spacing-20"]} + ${props.$insetTop || 0}px)`}; - right: ${(props) => - css`calc(${Spacing["spacing-20"]} + ${props.$insetRight || 0}px)`}; - } + ${(props) => + props.$hasFileInfo + ? css` + flex-shrink: 0; + background-color: ${Colour["bg-inverse"]}; + padding-top: calc( + ${Spacing["spacing-24"]} + ${props.$insetTop || 0}px + ); + padding-bottom: ${Spacing["spacing-24"]}; + padding-left: calc( + ${Spacing["spacing-32"]} + ${props.$insetLeft || 0}px + ); + padding-right: calc( + ${Spacing["spacing-32"]} + ${props.$insetRight || 0}px + ); + + ${MediaQuery.MaxWidth.sm} { + padding-top: calc( + ${Spacing["spacing-16"]} + ${props.$insetTop || 0}px + ); + padding-bottom: ${Spacing["spacing-16"]}; + padding-left: calc( + ${Spacing["spacing-20"]} + ${props.$insetLeft || 0}px + ); + padding-right: calc( + ${Spacing["spacing-20"]} + ${props.$insetRight || 0}px + ); + } + ` + : css` + position: absolute; + top: calc( + ${Spacing["spacing-48"]} + ${props.$insetTop || 0}px + ); + right: calc( + ${Spacing["spacing-48"]} + ${props.$insetRight || 0}px + ); + z-index: 5; + + ${MediaQuery.MaxWidth.sm} { + top: calc( + ${Spacing["spacing-20"]} + ${props.$insetTop || 0}px + ); + right: calc( + ${Spacing["spacing-20"]} + ${props.$insetRight || 0}px + ); + } + `} `; export const CloseButton = styled(IconButton)``; @@ -114,7 +168,8 @@ export const ImageGalleryContainer = styled.div` display: flex; flex-direction: column; width: 100%; - height: 100%; + flex: 1; + min-height: 0; `; export const ImageGalleryWrapper = styled.div` @@ -312,3 +367,41 @@ export const ThumbnailImage = styled(StatefulImage)` height: 100%; width: 100%; `; + +// ----------------------------------------------------------------------------- +// FILE INFO BAR STYLING +// ----------------------------------------------------------------------------- + +export const FileInfoTextWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: ${Spacing["spacing-8"]}; + overflow: hidden; + min-width: 0; + min-height: calc( + ${Font.Spec["body-lh-baseline"]} + ${Spacing["spacing-8"]} + + ${Font.Spec["body-lh-md"]} + ); + ${(props) => + props.$centerContent && + css` + justify-content: center; + `} +`; + +export const FileInfoFileName = styled(Typography.BodyBL)` + color: ${Colour["text-inverse"]}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +`; + +export const FileInfoFileSize = styled(Typography.BodyMD)` + color: ${Colour["text-inverse"]}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +`; diff --git a/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx b/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx index e721400c0..c4e12a41a 100644 --- a/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx +++ b/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from "react"; @@ -31,6 +32,9 @@ import { Chip, CloseButton, DeleteButton, + FileInfoFileName, + FileInfoFileSize, + FileInfoTextWrapper, FocusableImageRegion, ImageGalleryContainer, ImageGallerySlide, @@ -98,6 +102,13 @@ export const Component = ( (item) => isCustomItem(item) && !!item.itemLabel?.trim() ); const carouselItemNoun = hasAnyItemLabel ? "item" : "image"; + const hasFileInfo = useMemo( + () => + items.some( + (item) => item.fileName?.trim() || item.fileSize?.trim() + ), + [items] + ); const getItemAriaLabel = useCallback( (index: number) => { @@ -348,6 +359,35 @@ export const Component = ( ); }; + const renderFileInfo = () => { + const { fileName, fileSize } = currentItem ?? {}; + const trimmedName = fileName?.trim(); + const trimmedSize = fileSize?.trim(); + + return ( + + {trimmedName && ( + + {trimmedName} + + )} + {trimmedSize && ( + + {trimmedSize} + + )} + + ); + }; + const renderThumbnails = () => { return ( - + {hasFileInfo && renderFileInfo()} {!hideMagnifier && !isCustomItem(currentItem) && ( { - const images: FullscreenCarouselItemProps[] = []; + const images: FullscreenImageCarouselImageItemProps[] = []; for (let i = 0; i < size; i++) { const [width, height] = RESOLUTIONS[i % RESOLUTIONS.length]; images.push({ diff --git a/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx b/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx index 723cd4fb6..2730654a0 100644 --- a/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx +++ b/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx @@ -32,6 +32,10 @@ import { FullscreenImageCarousel } from "@lifesg/react-design-system/fullscreen- +## With file info + + + ## With custom content Slides can render arbitrary content via `renderContent`. If the content is not an image, you are recommended to set `itemLabel` so that the type of content can be described accurately to screen readers. diff --git a/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx b/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx index 014d8db83..66321442e 100644 --- a/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx +++ b/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx @@ -136,6 +136,44 @@ export const Configurable: StoryObj = { }, }; +export const WithFileInfo: StoryObj = { + render: (_args) => { + const [show, setShow] = useState(false); + return ( + <> + { + setShow((old) => !old); + }} + > + Show carousel + + setShow(false)} + /> + + ); + }, +}; + export const WithCustomContent: StoryObj = { render: (_args) => { const [show, setShow] = useState(false); diff --git a/stories/fullscreen-image-carousel/props-table.tsx b/stories/fullscreen-image-carousel/props-table.tsx index f51824caa..868acee5b 100644 --- a/stories/fullscreen-image-carousel/props-table.tsx +++ b/stories/fullscreen-image-carousel/props-table.tsx @@ -121,6 +121,18 @@ const DATA: ApiTableSectionProps[] = [ ), }, + { + name: "fileName", + description: + "The file name to display in the file info bar at the top", + propTypes: ["string"], + }, + { + name: "fileSize", + description: + 'The pre-formatted file size string to display in the file info bar at the top (e.g. "2.4 MB")', + propTypes: ["string"], + }, ], }, { diff --git a/tests/fullscreen-image-carousel/fullscreen-image-carousel.spec.tsx b/tests/fullscreen-image-carousel/fullscreen-image-carousel.spec.tsx index 5411abdb0..da2054e41 100644 --- a/tests/fullscreen-image-carousel/fullscreen-image-carousel.spec.tsx +++ b/tests/fullscreen-image-carousel/fullscreen-image-carousel.spec.tsx @@ -254,6 +254,91 @@ describe("Fullscreen Image Carousel", () => { expect(screen.getByLabelText("Delete image")).toBeInTheDocument(); }); }); + + describe("File info bar", () => { + it("should render fileName and fileSize when provided on the current item", () => { + render( + + ); + + expect(screen.getByTestId("file-info-bar")).toBeInTheDocument(); + expect(screen.getByTestId("file-info-name")).toHaveTextContent( + "photo-a.jpg" + ); + expect(screen.getByTestId("file-info-size")).toHaveTextContent( + "1.2 MB" + ); + }); + + it("should not render file info bar when no item has fileName or fileSize", () => { + render(); + + expect( + screen.queryByTestId("file-info-bar") + ).not.toBeInTheDocument(); + }); + + it("should render fileName without fileSize for a slide that has no fileSize", () => { + render( + + ); + + expect(screen.getByTestId("file-info-bar")).toBeInTheDocument(); + expect(screen.getByTestId("file-info-name")).toHaveTextContent( + "photo-c.jpg" + ); + expect( + screen.queryByTestId("file-info-size") + ).not.toBeInTheDocument(); + }); + + it("should not render fileName or fileSize for a slide that has neither", () => { + render( + + ); + + expect(screen.getByTestId("file-info-bar")).toBeInTheDocument(); + expect( + screen.queryByTestId("file-info-name") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("file-info-size") + ).not.toBeInTheDocument(); + }); + + it("should update the file info bar when navigating to a different slide", () => { + render( + + ); + + expect(screen.getByTestId("file-info-name")).toHaveTextContent( + "photo-a.jpg" + ); + + fireEvent.click(screen.getByTestId("forward-btn")); + + expect(screen.getByTestId("file-info-name")).toHaveTextContent( + "photo-b.jpg" + ); + expect(screen.getByTestId("file-info-size")).toHaveTextContent( + "840 KB" + ); + }); + }); }); // ============================================================================= @@ -295,3 +380,23 @@ const IMAGES_WITHOUT_THUMBNAIL = [ thumbnailSrc: "https://picsum.photos/id/163/100/100", }, ]; + +const IMAGES_WITH_FILE_INFO = [ + { + src: "https://picsum.photos/id/157/1600/900", + fileName: "photo-a.jpg", + fileSize: "1.2 MB", + }, + { + src: "https://picsum.photos/id/163/900/300", + fileName: "photo-b.jpg", + fileSize: "840 KB", + }, + { + src: "https://picsum.photos/id/445/300/300", + fileName: "photo-c.jpg", + }, + { + src: "https://picsum.photos/id/369/1000/1000", + }, +];