From 4f54f47a6253c9a7c38892a8532c45a9ad474d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Parram=C3=B3n=20Teixid=C3=B3?= Date: Wed, 15 Apr 2026 14:53:01 +0800 Subject: [PATCH 01/10] [DCUBESDQLR-2356][MPT] add custom content slot for fullscreen-image-carousel - Add discriminated union FullscreenCarouselItemProps (ImageCarouselItemProps | CustomCarouselItemProps) - Add renderContent per-item render prop for custom slide content - Add itemLabel per-item prop for accessible aria-label overrides - Hide magnifier button for custom content slides - Fall back to SlidePlaceholderImage in thumbnail strip when no src available - Update carousel-level aria-labels to generic wording when any item uses itemLabel --- .../fullscreen-image-carousel.tsx | 111 ++++++++++++------ src/fullscreen-image-carousel/types.ts | 23 +++- 2 files changed, 94 insertions(+), 40 deletions(-) diff --git a/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx b/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx index 5dfe7ad3b..3f5225bec 100644 --- a/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx +++ b/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx @@ -48,11 +48,20 @@ import { TopActionButtons, } from "./fullscreen-image-carousel.style"; import { + CustomCarouselItemProps, + FullscreenCarouselItemProps, FullscreenImageCarouselProps, FullscreenImageCarouselRef, ImageDimension, } from "./types"; +const isCustomItem = ( + item: FullscreenCarouselItemProps | undefined +): item is CustomCarouselItemProps => + !!item && + "renderContent" in item && + typeof item.renderContent === "function"; + export const Component = ( { items, @@ -87,14 +96,20 @@ export const Component = ( const imageRef = useRef(null); const diff = startX && endX ? startX - endX : 0; const currentItem = items[currentSlide]; + const hasCustomItemLabel = items.some( + (item) => item.itemLabel !== undefined + ); + const carouselItemNoun = hasCustomItemLabel ? "item" : "image"; const getImageAriaLabel = useCallback( (index: number) => { const item = items[index]; - const altText = item.alt?.trim() || ""; - return `${altText}. Image ${index + 1} of ${items.length}.`; + const altText = isCustomItem(item) ? "" : item.alt?.trim() || ""; + return `${altText}. ${hasCustomItemLabel ? "Item" : "Image"} ${ + index + 1 + } of ${items.length}.`; }, - [items] + [items, hasCustomItemLabel] ); useImperativeHandle( @@ -208,7 +223,7 @@ export const Component = ( }; const getZoomRatio = () => { - if (!currentItem) { + if (!currentItem || isCustomItem(currentItem)) { return; } @@ -285,29 +300,35 @@ export const Component = ( ref={isActive ? imageRef : null} tabIndex={isActive ? 0 : -1} > - (zoomRefs.current[index] = el)} - panning={{ - disabled: zoom <= 1, - }} - initialScale={1} - onZoom={handleZoom} - onZoomStop={handleZoom} - onWheel={handleZoom} - > - - - } - fit="scale-down" - retrieveImageDimension - setDimension={setDimension} - /> - - + {isCustomItem(item) ? ( + item.renderContent() + ) : ( + + (zoomRefs.current[index] = el) + } + panning={{ + disabled: zoom <= 1, + }} + initialScale={1} + onZoom={handleZoom} + onZoomStop={handleZoom} + onWheel={handleZoom} + > + + + } + fit="scale-down" + retrieveImageDimension + setDimension={setDimension} + /> + + + )} ); @@ -324,7 +345,9 @@ export const Component = ( > {items.map((item, index) => { - const src = item.thumbnailSrc ?? item.src; + const src = + item.thumbnailSrc ?? + (isCustomItem(item) ? undefined : item.src); return ( - + {src ? ( + + ) : ( + + )} ); @@ -353,7 +380,7 @@ export const Component = ( @@ -372,7 +399,7 @@ export const Component = ( {!hideNavigation && ( <> - {!hideMagnifier && ( + {!hideMagnifier && !isCustomItem(currentItem) && ( @@ -434,7 +463,11 @@ export const Component = ( )} diff --git a/src/fullscreen-image-carousel/types.ts b/src/fullscreen-image-carousel/types.ts index 70cbb3105..2d7fb6cee 100644 --- a/src/fullscreen-image-carousel/types.ts +++ b/src/fullscreen-image-carousel/types.ts @@ -27,12 +27,33 @@ export interface FullscreenImageCarouselProps insets?: Insets | undefined; } -export interface FullscreenCarouselItemProps { +export interface ImageCarouselItemProps { src: string; alt?: string | undefined; thumbnailSrc?: string | undefined; + fileName?: string | undefined; + fileSize?: string | undefined; + /** Label for this item used in aria-labels (e.g. "PDF"). Defaults to "image". When any item sets this, carousel-level aria-labels use generic "item" wording. */ + itemLabel?: string | undefined; + renderContent?: never; } +export interface CustomCarouselItemProps { + /** Optional src used as thumbnail fallback. If omitted, a placeholder is shown in the thumbnail strip. */ + src?: string | undefined; + thumbnailSrc?: string | undefined; + fileName?: string | undefined; + fileSize?: string | undefined; + /** Label for this item used in aria-labels (e.g. "PDF"). Defaults to "image". When any item sets this, carousel-level aria-labels use generic "item" wording. */ + itemLabel?: string | undefined; + /** Render prop for the full slide area. Consumer is responsible for the entire slide content (e.g. an iframe, embed, or custom viewer). */ + renderContent: () => React.ReactNode; +} + +export type FullscreenCarouselItemProps = + | ImageCarouselItemProps + | CustomCarouselItemProps; + export interface ImageDimension { width: number; height: number; From 10c355454920fa6a20242b145a9a0840c8f6da89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Parram=C3=B3n=20Teixid=C3=B3?= Date: Wed, 15 Apr 2026 15:25:38 +0800 Subject: [PATCH 02/10] [DCUBESDQLR-2356][MPT] add tests, story, and docs for custom content slot - Add WithCustomContent story with PDF iframe and pdf-icon thumbnail - Update props-table with renderContent and itemLabel docs - Add tests for renderContent, itemLabel aria-labels, magnifier hide, thumbnail placeholder - Remove fileName/fileSize from this branch (belongs to file-info-bar branch) - Add pdf-icon.svg asset from sgw-dl --- public/img/pdf-icon.svg | 8 ++ src/fullscreen-image-carousel/types.ts | 4 - .../fullscreen-image-carousel.mdx | 6 + .../fullscreen-image-carousel.stories.tsx | 41 +++++++ .../fullscreen-image-carousel/props-table.tsx | 48 +++++++- .../fullscreen-image-carousel.spec.tsx | 105 ++++++++++++++++++ 6 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 public/img/pdf-icon.svg diff --git a/public/img/pdf-icon.svg b/public/img/pdf-icon.svg new file mode 100644 index 000000000..6e78f081f --- /dev/null +++ b/public/img/pdf-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/fullscreen-image-carousel/types.ts b/src/fullscreen-image-carousel/types.ts index 2d7fb6cee..ee6e19d4b 100644 --- a/src/fullscreen-image-carousel/types.ts +++ b/src/fullscreen-image-carousel/types.ts @@ -31,8 +31,6 @@ export interface ImageCarouselItemProps { src: string; alt?: string | undefined; thumbnailSrc?: string | undefined; - fileName?: string | undefined; - fileSize?: string | undefined; /** Label for this item used in aria-labels (e.g. "PDF"). Defaults to "image". When any item sets this, carousel-level aria-labels use generic "item" wording. */ itemLabel?: string | undefined; renderContent?: never; @@ -42,8 +40,6 @@ export interface CustomCarouselItemProps { /** Optional src used as thumbnail fallback. If omitted, a placeholder is shown in the thumbnail strip. */ src?: string | undefined; thumbnailSrc?: string | undefined; - fileName?: string | undefined; - fileSize?: string | undefined; /** Label for this item used in aria-labels (e.g. "PDF"). Defaults to "image". When any item sets this, carousel-level aria-labels use generic "item" wording. */ itemLabel?: string | undefined; /** Render prop for the full slide area. Consumer is responsible for the entire slide content (e.g. an iframe, embed, or custom viewer). */ diff --git a/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx b/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx index 414ba190f..eb0391d74 100644 --- a/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx +++ b/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx @@ -32,6 +32,12 @@ import { FullscreenImageCarousel } from "@lifesg/react-design-system/fullscreen- +## With custom content + +Slides can render arbitrary content via `renderContent`. When `itemLabel` is set, carousel aria-labels switch to generic wording. + + + ## Component API diff --git a/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx b/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx index 4e47dcb60..f9a87694e 100644 --- a/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx +++ b/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx @@ -135,3 +135,44 @@ export const Configurable: StoryObj = { ); }, }; + +export const WithCustomContent: StoryObj = { + render: (_args) => { + const [show, setShow] = useState(false); + return ( + <> + { + setShow((old) => !old); + }} + > + Show carousel + + ( +