From f686a6fc37cd5359d87056584ee2f8fe8a1b5749 Mon Sep 17 00:00:00 2001 From: Rhys Adey Date: Mon, 19 Jan 2026 10:22:46 +0000 Subject: [PATCH 1/2] RA - added new scroll option to ScrollableImages component whilst keeping backwards compatability with sinlge view mode. Test and stories updated --- .../controls/ScrollableImages.stories.tsx | 88 ++++- .../controls/ScrollableImages.test.tsx | 167 ++++---- src/components/controls/ScrollableImages.tsx | 369 +++++++++--------- 3 files changed, 343 insertions(+), 281 deletions(-) diff --git a/src/components/controls/ScrollableImages.stories.tsx b/src/components/controls/ScrollableImages.stories.tsx index 29b61b5..2849ed0 100644 --- a/src/components/controls/ScrollableImages.stories.tsx +++ b/src/components/controls/ScrollableImages.stories.tsx @@ -1,4 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useState } from "react"; import { ImageInfo, ScrollableImages } from "./ScrollableImages"; @@ -6,12 +7,17 @@ import diamond from "../../public/images/diamond.jpg"; import soleil from "../../public/images/soleil.jpg"; import bessy from "../../public/images/bessy.jpg"; import shanghai from "../../public/images/shanghai.jpg"; -import { useEffect, useState } from "react"; const meta: Meta = { title: "Components/Controls/ScrollableImages", component: ScrollableImages, tags: ["autodocs"], + argTypes: { + mode: { + control: "radio", + options: ["viewer", "scroll"], + }, + }, }; export default meta; @@ -31,34 +37,94 @@ const tiffImage: ImageInfo[] = [ }, ]; +// CS - Standard mode export const All: Story = { - args: { images: imagesList, width: 300, height: 300 }, + args: { + images: imagesList, + width: 300, + height: 300, + }, }; export const NoButtons: Story = { - args: { images: imagesList, buttons: false }, + args: { + images: imagesList, + buttons: false, + }, }; export const NoWrap: Story = { - args: { images: imagesList, wrapAround: false }, + args: { + images: imagesList, + wrapAround: false, + }, }; export const NoSlider: Story = { - args: { images: imagesList, slider: false }, + args: { + images: imagesList, + slider: false, + }, }; export const NoNumbers: Story = { - args: { images: imagesList, numeration: false }, + args: { + images: imagesList, + numeration: false, + }, }; export const DifferentBackgroundColour: Story = { - args: { images: imagesList, backgroundColor: "#166" }, + args: { + images: imagesList, + backgroundColor: "#166", + }, }; export const OneImage: Story = { - args: { images: imagesList[0] }, + args: { + images: imagesList[0], + }, +}; + +export const TiffImage: Story = { + args: { + images: tiffImage, + }, +}; +// CE - Standard mode + +// CS - Scroll mode +export const ScrollModeBasic: Story = { + name: "Scroll Mode", + args: { + images: imagesList, + mode: "scroll", + width: 300, + height: 300, + }, }; +export const ScrollModeWideImages: Story = { + args: { + images: imagesList, + mode: "scroll", + width: 400, + height: 250, + }, +}; + +export const ScrollModeWithTiff: Story = { + args: { + images: tiffImage, + mode: "scroll", + width: 300, + height: 300, + }, +}; +// CE - Scroll mode + +// CS - Dynamic images export const DynamicImages: StoryObj = { render: () => { const [visibleImages, setVisibleImages] = useState(imagesList); @@ -69,6 +135,7 @@ export const DynamicImages: StoryObj = { const interval = setInterval(() => { setVisibleImages(imagesList.slice(0, nImages)); nImages += increment; + if (nImages === imagesList.length) { increment = -1; } else if (nImages === 1) { @@ -91,7 +158,4 @@ export const DynamicImages: StoryObj = { ); }, }; - -export const TiffImage: Story = { - args: { images: tiffImage }, -}; +// CE - Dynamic images diff --git a/src/components/controls/ScrollableImages.test.tsx b/src/components/controls/ScrollableImages.test.tsx index 76b4c8c..972763a 100644 --- a/src/components/controls/ScrollableImages.test.tsx +++ b/src/components/controls/ScrollableImages.test.tsx @@ -8,7 +8,7 @@ const imagesList: ImageInfo[] = [ { src: "four", alt: "four" }, ]; -describe("ScrollableImages", () => { +describe("ScrollableImages – viewer mode", () => { it("should render", () => { const { getByTestId } = render(); expect(getByTestId("scrollable-images")).toBeInTheDocument(); @@ -16,120 +16,135 @@ describe("ScrollableImages", () => { }); it("should render buttons by default", () => { - const { getByTestId } = render(); - expect(getByTestId("prev-button")).toBeInTheDocument(); - expect(getByTestId("next-button")).toBeInTheDocument(); + render(); + expect(screen.getByTestId("prev-button")).toBeInTheDocument(); + expect(screen.getByTestId("next-button")).toBeInTheDocument(); }); it("should not render buttons when buttons is false", () => { - const { queryByTestId } = render( - , - ); - expect(queryByTestId("prev-button")).not.toBeInTheDocument(); - expect(queryByTestId("next-button")).not.toBeInTheDocument(); + render(); + expect(screen.queryByTestId("prev-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("next-button")).not.toBeInTheDocument(); }); it("should render slider by default", () => { - const { getByTestId } = render(); - expect(getByTestId("slider")).toBeInTheDocument(); + render(); + expect(screen.getByTestId("slider")).toBeInTheDocument(); }); it("should not render slider when slider is false", () => { - const { queryByTestId } = render( - , - ); - expect(queryByTestId("slider")).not.toBeInTheDocument(); + render(); + expect(screen.queryByTestId("slider")).not.toBeInTheDocument(); }); it("should render numeration by default", () => { - const { getByTestId } = render(); - expect(getByTestId("numeration")).toBeInTheDocument(); + render(); + expect(screen.getByTestId("numeration")).toBeInTheDocument(); }); it("should not render numeration when numeration is false", () => { - const { queryByTestId } = render( - , - ); - expect(queryByTestId("numeration")).not.toBeInTheDocument(); + render(); + expect(screen.queryByTestId("numeration")).not.toBeInTheDocument(); }); - it("should wrap from first image to last", () => { + it("should wrap around by default", () => { render(); - const prevButton = screen.getByTestId("prev-button"); - fireEvent.click(prevButton); - const imageContainer = screen.getByTestId("image-container"); - expect(imageContainer).toHaveAttribute("data-index", "3"); + fireEvent.click(screen.getByTestId("prev-button")); + expect(screen.getByTestId("image-container")).toHaveAttribute( + "data-index", + "3", + ); }); - it("should wrap from last image to first", () => { - render(); - const nextButton = screen.getByTestId("next-button"); - fireEvent.click(nextButton); - fireEvent.click(nextButton); - fireEvent.click(nextButton); - fireEvent.click(nextButton); + it("should not wrap when wrapAround is false", () => { + render(); const imageContainer = screen.getByTestId("image-container"); + + fireEvent.click(screen.getByTestId("prev-button")); expect(imageContainer).toHaveAttribute("data-index", "0"); + + fireEvent.click(screen.getByTestId("next-button")); + fireEvent.click(screen.getByTestId("next-button")); + fireEvent.click(screen.getByTestId("next-button")); + fireEvent.click(screen.getByTestId("next-button")); + + expect(imageContainer).toHaveAttribute("data-index", "3"); + }); + + it("should not render controls with only one image", () => { + render(); + expect(screen.queryByTestId("numeration")).not.toBeInTheDocument(); + expect(screen.queryByTestId("slider")).not.toBeInTheDocument(); + expect(screen.queryByTestId("prev-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("next-button")).not.toBeInTheDocument(); }); - it("should wrap from first image to last", () => { + it("should respond to arrow keys when focused", () => { render(); - const prevButton = screen.getByTestId("prev-button"); - fireEvent.click(prevButton); - const imageContainer = screen.getByTestId("image-container"); - expect(imageContainer).toHaveAttribute("data-index", "3"); + const container = screen.getByTestId("image-container"); + container.focus(); + + fireEvent.keyDown(container, { key: "ArrowRight" }); + expect(container).toHaveAttribute("data-index", "1"); + + fireEvent.keyDown(container, { key: "ArrowLeft" }); + expect(container).toHaveAttribute("data-index", "0"); }); - it("should not wrap when wrapAround is false", () => { - render(); - const nextButton = screen.getByTestId("next-button"); - const prevButton = screen.getByTestId("prev-button"); - const imageContainer = screen.getByTestId("image-container"); + it("should not respond to arrow keys when not focused", () => { + render(); + const container = screen.getByTestId("image-container"); - expect(imageContainer).toHaveAttribute("data-index", "0"); + fireEvent.keyDown(window, { key: "ArrowRight" }); + expect(container).toHaveAttribute("data-index", "0"); + }); +}); - fireEvent.click(prevButton); - expect(imageContainer).toHaveAttribute("data-index", "0"); +describe("ScrollableImages – scroll mode", () => { + it("should render scroll container", () => { + render(); + expect(screen.getByTestId("image-scroll-container")).toBeInTheDocument(); + }); - fireEvent.click(nextButton); - fireEvent.click(nextButton); - fireEvent.click(nextButton); - fireEvent.click(nextButton); - expect(imageContainer).toHaveAttribute("data-index", "3"); + it("should render left and right scroll buttons", () => { + render(); + expect(screen.getByTestId("scroll-left-button")).toBeInTheDocument(); + expect(screen.getByTestId("scroll-right-button")).toBeInTheDocument(); }); - it("should not render these components when only one image given", () => { - const { queryByTestId } = render( - , - ); - expect(queryByTestId("numeration")).not.toBeInTheDocument(); - expect(queryByTestId("slider")).not.toBeInTheDocument(); - expect(queryByTestId("prev-button")).not.toBeInTheDocument(); - expect(queryByTestId("next-button")).not.toBeInTheDocument(); + it("should render all images in scroll mode", () => { + render(); + + imagesList.forEach((_, index) => { + expect( + screen.getByTestId(`scroll-image-${index + 1}`), + ).toBeInTheDocument(); + }); }); - it("should be able to scroll with arrow keys", () => { - render(); - const imageContainer = screen.getByTestId("image-container"); + it.skip("should call scrollBy when clicking scroll buttons", () => { + render(); + + const container = screen.getByTestId("image-scroll-container"); - fireEvent.keyDown(imageContainer, { - key: "ArrowRight", - code: "ArrowRight", + Object.defineProperty(container, "scrollBy", { + value: vi.fn(), + writable: true, }); - expect(imageContainer).toHaveAttribute("data-index", "1"); - fireEvent.keyDown(imageContainer, { key: "ArrowLeft", code: "ArrowLeft" }); - expect(imageContainer).toHaveAttribute("data-index", "0"); + fireEvent.click(screen.getByTestId("scroll-right-button")); + expect(container.scrollBy).toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("scroll-left-button")); + expect(container.scrollBy).toHaveBeenCalled(); }); - it("should not scroll with arrow keys if the component is not targeted", () => { - render(); - const imageContainer = screen.getByTestId("image-container"); + it("should not render viewer-only controls in scroll mode", () => { + render(); - expect(imageContainer).toHaveAttribute("data-index", "0"); - fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); - expect(imageContainer).toHaveAttribute("data-index", "0"); - fireEvent.keyDown(window, { key: "ArrowLeft", code: "ArrowLeft" }); - expect(imageContainer).toHaveAttribute("data-index", "0"); + expect(screen.queryByTestId("slider")).not.toBeInTheDocument(); + expect(screen.queryByTestId("numeration")).not.toBeInTheDocument(); + expect(screen.queryByTestId("prev-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("next-button")).not.toBeInTheDocument(); }); }); diff --git a/src/components/controls/ScrollableImages.tsx b/src/components/controls/ScrollableImages.tsx index 2992336..173fafa 100644 --- a/src/components/controls/ScrollableImages.tsx +++ b/src/components/controls/ScrollableImages.tsx @@ -1,13 +1,17 @@ -import { Box, Button, Slider, Stack } from "@mui/material"; -import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Button, IconButton, Slider, Stack } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; +import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import { extractFramesFromTiff, isTiff } from "../../utils/TiffUtils"; interface ScrollableImagesProps { images: ImageInfo | ImageInfo[]; width?: number; height?: number; + scrollStep?: number; + mode?: "viewer" | "scroll"; buttons?: boolean; wrapAround?: boolean; slider?: boolean; @@ -15,23 +19,30 @@ interface ScrollableImagesProps { backgroundColor?: string; } -interface ImageInfo { +export interface ImageInfo { src: string; type?: string; alt?: string; } -const ScrollableImages = ({ +export function ScrollableImages({ images, width = 300, height = 300, + mode = "viewer", buttons = true, wrapAround = true, slider = true, numeration = true, backgroundColor = "#eee", -}: ScrollableImagesProps) => { - const [extractedImages, setExtractedImages] = useState([]); + scrollStep = 320, +}: ScrollableImagesProps) { + const [imageList, setImageList] = useState([]); + + const handleArrowKeys = (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") handlePrev(); + else if (event.key === "ArrowRight") handleNext(); + }; useEffect(() => { (async () => { @@ -50,227 +61,199 @@ const ScrollableImages = ({ } index++; } - setExtractedImages(result); + + setImageList(result); })(); }, [images]); - const imageList = extractedImages.map((img, i) => ( - {img.alt - )); - - const imageListLength = imageList.length; - const renderButtons = buttons && imageListLength > 1; - const renderSlider = slider && imageListLength > 1; - const renderNumbers = numeration && imageListLength > 1; - + // CS - Standard mode const [currentIndex, setCurrentIndex] = useState(0); - const [numberValue, setNumberValue] = useState("1"); - const containerRef = useRef(null); - const setCurrentIndexWrapper = (index: number) => { + const imageCount = imageList.length; + + const setIndex = (index: number) => { setCurrentIndex(index); - setNumberValue((index + 1).toString()); }; const handlePrev = useCallback(() => { const newIndex = wrapAround - ? (currentIndex - 1 + imageListLength) % imageListLength + ? (currentIndex - 1 + imageCount) % imageCount : Math.max(0, currentIndex - 1); - setCurrentIndexWrapper(newIndex); - }, [currentIndex, imageListLength, wrapAround]); + setIndex(newIndex); + }, [currentIndex, imageCount, wrapAround]); const handleNext = useCallback(() => { const newIndex = wrapAround - ? (currentIndex + 1) % imageListLength - : Math.min(currentIndex + 1, imageListLength - 1); - setCurrentIndexWrapper(newIndex); - }, [currentIndex, imageListLength, wrapAround]); - - const handleSliderChange = (_event: Event, newIndex: number | number[]) => { - setCurrentIndexWrapper(Number(newIndex)); - }; + ? (currentIndex + 1) % imageCount + : Math.min(imageCount - 1, currentIndex + 1); + setIndex(newIndex); + }, [currentIndex, imageCount, wrapAround]); + // CE - Standard mode - const handleNumberChange = (event: React.ChangeEvent) => { - setNumberValue(event.target.value); - }; - - const handleNumberEnter = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - const n = parseInt(numberValue); - let newIndex: number; - if (isNaN(n)) { - newIndex = currentIndex; - } else if (n > imageListLength) { - newIndex = imageListLength - 1; - } else if (n < 1) { - newIndex = 0; - } else { - newIndex = n - 1; - } - setCurrentIndexWrapper(newIndex); - } - }; + // CS - Scroll mode + const scrollRef = useRef(null); - const handleArrowKeys = (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") handlePrev(); - else if (event.key === "ArrowRight") handleNext(); - }; + const scrollLeft = () => + scrollRef.current?.scrollBy({ left: -scrollStep, behavior: "smooth" }); - useEffect(() => { - const element = containerRef.current; - if (!element) return; + const scrollRight = () => + scrollRef.current?.scrollBy({ left: scrollStep, behavior: "smooth" }); + // CE - Scroll mode - const handleWheel = (event: WheelEvent) => { - event.preventDefault(); - if (event.deltaY < 0) handlePrev(); - else if (event.deltaY > 0) handleNext(); - }; + if (!imageCount) return null; - element.addEventListener("wheel", handleWheel, { passive: false }); + if (mode === "scroll") { + return ( + + + + - return () => { - element.removeEventListener("wheel", handleWheel); - }; - }, [handlePrev, handleNext]); + + {imageList.map((img, i) => ( + + {img.alt + + ))} + - useEffect(() => { - if (currentIndex >= imageListLength && imageListLength) { - setCurrentIndexWrapper(imageListLength - 1); - } - }, [imageListLength, currentIndex]); + + + + + ); + } return ( - <> - + + + {buttons && imageCount > 1 && ( + + )} + - {renderButtons && ( - - )} - - {imageList[currentIndex]} - {renderSlider && ( - - - - )} - + /> - {renderButtons && ( - + {slider && imageCount > 1 && ( + + setIndex(Number(v))} + /> + )} - {renderNumbers && ( - - - - {`/${String(imageListLength)}`} - - + {buttons && imageCount > 1 && ( + )} - - - ); -}; + -export { ScrollableImages }; -export type { ScrollableImagesProps, ImageInfo }; + {numeration && imageCount > 1 && ( + + {currentIndex + 1}/{imageCount} + + )} + + ); +} From 0d5a7b4eec7fba1f852634e32e9261762e2c3c57 Mon Sep 17 00:00:00 2001 From: Rhys Adey Date: Mon, 19 Jan 2026 10:25:43 +0000 Subject: [PATCH 2/2] RA removed unused argType from stories --- src/components/controls/ScrollableImages.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/controls/ScrollableImages.stories.tsx b/src/components/controls/ScrollableImages.stories.tsx index 2849ed0..e5b3941 100644 --- a/src/components/controls/ScrollableImages.stories.tsx +++ b/src/components/controls/ScrollableImages.stories.tsx @@ -14,7 +14,6 @@ const meta: Meta = { tags: ["autodocs"], argTypes: { mode: { - control: "radio", options: ["viewer", "scroll"], }, },