Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 88 additions & 42 deletions src/fullscreen-image-carousel/fullscreen-image-carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,14 +94,25 @@ export const Component = (
const imageRef = useRef<HTMLDivElement>(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
}.`;
Comment thread
mparramont marked this conversation as resolved.

return prefix ? `${prefix}. ${positionLabel}` : positionLabel;
},
[items]
[items, hasAnyItemLabel]
);

useImperativeHandle<FullscreenImageCarouselRef, FullscreenImageCarouselRef>(
Expand Down Expand Up @@ -208,7 +226,7 @@ export const Component = (
};

const getZoomRatio = () => {
if (!currentItem) {
if (!currentItem || isCustomItem(currentItem)) {
return;
}

Expand Down Expand Up @@ -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");
}
);
};
Expand All @@ -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");
}
);
};
Expand All @@ -278,36 +296,50 @@ 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 (
<ImageGallerySlide key={index} data-testid="slide-item">
<FocusableImageRegion
ref={isActive ? imageRef : null}
tabIndex={isActive ? 0 : -1}
>
<TransformWrapper
ref={(el) => (zoomRefs.current[index] = el)}
panning={{
disabled: zoom <= 1,
}}
initialScale={1}
onZoom={handleZoom}
onZoomStop={handleZoom}
onWheel={handleZoom}
>
<TransformComponent>
<SlideImage
src={item.src}
alt={getImageAriaLabel(index)}
placeholder={
<SlidePlaceholderImage />
}
fit="scale-down"
retrieveImageDimension
setDimension={setDimension}
/>
</TransformComponent>
</TransformWrapper>
{isCustomItem(item) ? (
isActiveOrAdjacent ? (
item.renderContent()
) : (
<SlidePlaceholderImage />
)
) : (
<TransformWrapper
ref={(el) =>
(zoomRefs.current[index] = el)
}
panning={{
disabled: zoom <= 1,
}}
initialScale={1}
onZoom={handleZoom}
onZoomStop={handleZoom}
onWheel={handleZoom}
>
<TransformComponent>
<SlideImage
src={item.src}
alt={getItemAriaLabel(index)}
placeholder={
<SlidePlaceholderImage />
}
fit="scale-down"
retrieveImageDimension
setDimension={setDimension}
/>
</TransformComponent>
</TransformWrapper>
)}
</FocusableImageRegion>
</ImageGallerySlide>
);
Expand All @@ -324,7 +356,9 @@ export const Component = (
>
<ThumbnailWrapper>
{items.map((item, index) => {
const src = item.thumbnailSrc ?? item.src;
const src = isCustomItem(item)
? item.thumbnailSrc
: item.thumbnailSrc ?? item.src;
return (
<ThumbnailItemContainer key={index}>
<ThumbnailItem
Expand All @@ -335,11 +369,15 @@ export const Component = (
(thumbnailRefs.current[index] = el)
}
>
<ThumbnailImage
src={src}
alt={`Thumbnail ${index + 1}`}
fit="cover"
/>
{src ? (
<ThumbnailImage
src={src}
alt={`Thumbnail ${index + 1}`}
fit="cover"
/>
) : (
<SlidePlaceholderImage />
Comment thread
mparramont marked this conversation as resolved.
)}
</ThumbnailItem>
</ThumbnailItemContainer>
);
Expand All @@ -353,7 +391,7 @@ export const Component = (
<ModalV2
{...otherProps}
data-testid="image-carousel-modal"
aria-label="Image carousel"
aria-label={hasAnyItemLabel ? "Carousel" : "Image carousel"}
show={show}
disableInitialFocus
>
Expand All @@ -372,7 +410,7 @@ export const Component = (
{!hideNavigation && (
<>
<ArrowButton
aria-label="Previous image"
aria-label={`Previous ${carouselItemNoun}`}
data-testid="prev-btn"
$position="left"
onClick={goToPrevSlide}
Expand All @@ -382,7 +420,7 @@ export const Component = (
<ChevronLeftIcon aria-hidden />
</ArrowButton>
<ArrowButton
aria-label="Next image"
aria-label={`Next ${carouselItemNoun}`}
data-testid="forward-btn"
$position="right"
onClick={goToNextSlide}
Expand Down Expand Up @@ -410,7 +448,7 @@ export const Component = (
$insetTop={insets?.top}
$insetRight={insets?.right}
>
{!hideMagnifier && (
{!hideMagnifier && !isCustomItem(currentItem) && (
<MagnifierButton
aria-label={zoom === 1 ? "Zoom in" : "Zoom out"}
onClick={handleMagnifier}
Expand All @@ -425,7 +463,11 @@ export const Component = (

{onDelete && (
<DeleteButton
aria-label="Delete image"
aria-label={`Delete ${
(isCustomItem(currentItem) &&
currentItem.itemLabel?.trim()) ||
"image"
}`}
data-testid="delete-btn"
onClick={handleDelete}
>
Expand All @@ -434,7 +476,11 @@ export const Component = (
)}

<CloseButton
aria-label="Close image carousel"
aria-label={
hasAnyItemLabel
? "Close carousel"
: "Close image carousel"
}
onClick={onClose}
>
<CrossIcon aria-hidden />
Expand Down
25 changes: 22 additions & 3 deletions src/fullscreen-image-carousel/types.ts
Comment thread
qroll marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,45 @@ 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;
hideNavigation?: boolean | undefined;
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ import { FullscreenImageCarousel } from "@lifesg/react-design-system/fullscreen-

<Canvas of={FullscreenImageCarouselStories.Configurable} />

## 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.

<Canvas of={FullscreenImageCarouselStories.WithCustomContent} />

## Component API

<PropsTable />
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,63 @@ export const Configurable: StoryObj<Component> = {
);
},
};

export const WithCustomContent: StoryObj<Component> = {
render: (_args) => {
const [show, setShow] = useState(false);
return (
<>
<Button.Default
onClick={() => {
setShow((old) => !old);
}}
>
Show carousel
</Button.Default>
<FullscreenImageCarousel
items={[
{
type: "custom",
itemLabel: "PDF",
thumbnailSrc:
"https://assets.life.gov.sg/react-design-system/img/upload/pdf.svg",
renderContent: () => (
<iframe
src="https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf"
title="PDF preview"
style={{
width: "100%",
height: "100%",
border: "none",
}}
/>
),
},
{
type: "custom",
itemLabel: "document",
renderContent: () => (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
color: "#fff",
fontSize: 24,
}}
>
No thumbnail provided
</div>
),
},
]}
show={show}
onDelete={() => undefined}
onClose={() => setShow(false)}
/>
</>
);
},
};
Loading
Loading