diff --git a/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx b/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx index 5dfe7ad3b..e721400c0 100644 --- a/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx +++ b/src/fullscreen-image-carousel/fullscreen-image-carousel.tsx @@ -48,11 +48,18 @@ import { TopActionButtons, } from "./fullscreen-image-carousel.style"; import { + FullscreenImageCarouselCustomItemProps, + FullscreenImageCarouselItemProps, FullscreenImageCarouselProps, FullscreenImageCarouselRef, ImageDimension, } from "./types"; +const isCustomItem = ( + item: FullscreenImageCarouselItemProps | undefined +): item is FullscreenImageCarouselCustomItemProps => + !!item && item.type === "custom"; + export const Component = ( { items, @@ -87,14 +94,25 @@ export const Component = ( const imageRef = useRef(null); const diff = startX && endX ? startX - endX : 0; const currentItem = items[currentSlide]; + const hasAnyItemLabel = items.some( + (item) => isCustomItem(item) && !!item.itemLabel?.trim() + ); + const carouselItemNoun = hasAnyItemLabel ? "item" : "image"; - const getImageAriaLabel = useCallback( + const getItemAriaLabel = useCallback( (index: number) => { const item = items[index]; - const altText = item.alt?.trim() || ""; - return `${altText}. Image ${index + 1} of ${items.length}.`; + const itemTypeLabel = hasAnyItemLabel ? "Item" : "Image"; + const prefix = isCustomItem(item) + ? item.itemLabel?.trim() || "" + : item.alt?.trim() || ""; + const positionLabel = `${itemTypeLabel} ${index + 1} of ${ + items.length + }.`; + + return prefix ? `${prefix}. ${positionLabel}` : positionLabel; }, - [items] + [items, hasAnyItemLabel] ); useImperativeHandle( @@ -208,7 +226,7 @@ export const Component = ( }; const getZoomRatio = () => { - if (!currentItem) { + if (!currentItem || isCustomItem(currentItem)) { return; } @@ -243,7 +261,7 @@ export const Component = ( (prev) => (prev === 0 ? items.length - 1 : prev - 1), (slide) => { clearAnnouncer("polite"); - announce(getImageAriaLabel(slide), "polite"); + announce(getItemAriaLabel(slide), "polite"); } ); }; @@ -254,7 +272,7 @@ export const Component = ( (prev) => (prev === items.length - 1 ? 0 : prev + 1), (slide) => { clearAnnouncer("polite"); - announce(getImageAriaLabel(slide), "polite"); + announce(getItemAriaLabel(slide), "polite"); } ); }; @@ -278,6 +296,10 @@ export const Component = ( > {items.map((item, index) => { const isActive = index === currentSlide; + const isActiveOrAdjacent = + Math.abs(index - currentSlide) <= 1 || + (currentSlide === 0 && index === items.length - 1) || + (currentSlide === items.length - 1 && index === 0); return ( @@ -285,29 +307,39 @@ 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) ? ( + isActiveOrAdjacent ? ( + 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 +356,9 @@ export const Component = ( > {items.map((item, index) => { - const src = item.thumbnailSrc ?? item.src; + const src = isCustomItem(item) + ? item.thumbnailSrc + : item.thumbnailSrc ?? item.src; return ( - + {src ? ( + + ) : ( + + )} ); @@ -353,7 +391,7 @@ export const Component = ( @@ -372,7 +410,7 @@ export const Component = ( {!hideNavigation && ( <> - {!hideMagnifier && ( + {!hideMagnifier && !isCustomItem(currentItem) && ( @@ -434,7 +476,11 @@ export const Component = ( )} diff --git a/src/fullscreen-image-carousel/types.ts b/src/fullscreen-image-carousel/types.ts index 70cbb3105..3cb19d334 100644 --- a/src/fullscreen-image-carousel/types.ts +++ b/src/fullscreen-image-carousel/types.ts @@ -13,7 +13,7 @@ export interface FullscreenImageCarouselProps ModalProps, "show" | "rootComponentId" | "animationFrom" | "zIndex" > { - items: FullscreenCarouselItemProps[]; + items: FullscreenImageCarouselItemProps[]; /** The index of the visible item, starts from 0 */ initialActiveItemIndex?: number | undefined; hideThumbnail?: boolean | undefined; @@ -21,18 +21,37 @@ export interface FullscreenImageCarouselProps hideCounter?: boolean | undefined; hideMagnifier?: boolean | undefined; onDelete?: - | ((item: FullscreenCarouselItemProps, index: number) => void) + | ((item: FullscreenImageCarouselItemProps, index: number) => void) | undefined; onClose?: (() => void) | undefined; insets?: Insets | undefined; } -export interface FullscreenCarouselItemProps { +export interface FullscreenImageCarouselImageItemProps { + type?: "image" | undefined; src: string; alt?: string | undefined; thumbnailSrc?: string | undefined; + renderContent?: never; } +/** @deprecated Use FullscreenImageCarouselImageItemProps instead */ +export type FullscreenCarouselItemProps = FullscreenImageCarouselImageItemProps; + +export interface FullscreenImageCarouselCustomItemProps { + type: "custom"; + /** The thumbnail image src. If omitted, a placeholder is shown in the thumbnail strip. */ + thumbnailSrc?: 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 FullscreenImageCarouselItemProps = + | FullscreenImageCarouselImageItemProps + | FullscreenImageCarouselCustomItemProps; + export interface ImageDimension { width: number; height: number; diff --git a/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx b/stories/fullscreen-image-carousel/fullscreen-image-carousel.mdx index 414ba190f..723cd4fb6 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`. 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. + + + ## 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..014d8db83 100644 --- a/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx +++ b/stories/fullscreen-image-carousel/fullscreen-image-carousel.stories.tsx @@ -135,3 +135,63 @@ export const Configurable: StoryObj = { ); }, }; + +export const WithCustomContent: StoryObj = { + render: (_args) => { + const [show, setShow] = useState(false); + return ( + <> + { + setShow((old) => !old); + }} + > + Show carousel + + ( +