From 58e77661ad99acb5d3f6e10cbfca981345dc8704 Mon Sep 17 00:00:00 2001 From: Niels Roozemond Date: Thu, 3 Oct 2024 16:37:40 +0200 Subject: [PATCH] feat: Image slider (#1595) Co-authored-by: Ruben Sibon Co-authored-by: Vincent Smedinga --- .../css/src/components/image-slider/README.md | 23 +++ .../components/image-slider/image-slider.scss | 89 +++++++++ packages/css/src/components/index.scss | 1 + packages/css/src/components/screen/README.md | 1 + .../css/src/components/spotlight/README.md | 1 + .../src/ImageSlider/ImageSlider.test.tsx | 89 +++++++++ .../react/src/ImageSlider/ImageSlider.tsx | 188 ++++++++++++++++++ .../src/ImageSlider/ImageSliderContext.tsx | 27 +++ .../ImageSlider/ImageSliderControls.test.tsx | 28 +++ .../src/ImageSlider/ImageSliderControls.tsx | 53 +++++ .../src/ImageSlider/ImageSliderItem.test.tsx | 47 +++++ .../react/src/ImageSlider/ImageSliderItem.tsx | 34 ++++ .../ImageSlider/ImageSliderScroller.test.tsx | 47 +++++ .../src/ImageSlider/ImageSliderScroller.tsx | 20 ++ .../ImageSliderThumbnails.test.tsx | 68 +++++++ .../src/ImageSlider/ImageSliderThumbnails.tsx | 83 ++++++++ packages/react/src/ImageSlider/README.md | 5 + packages/react/src/ImageSlider/index.ts | 3 + packages/react/src/index.ts | 1 + .../components/ams/image-slider.tokens.json | 26 +++ storybook/src/components/Image/Image.docs.mdx | 8 +- .../src/components/Image/Image.stories.tsx | 2 +- .../ImageSlider/ImageSlider.docs.mdx | 23 +++ .../ImageSlider/ImageSlider.stories.tsx | 84 ++++++++ 24 files changed, 946 insertions(+), 5 deletions(-) create mode 100644 packages/css/src/components/image-slider/README.md create mode 100644 packages/css/src/components/image-slider/image-slider.scss create mode 100644 packages/react/src/ImageSlider/ImageSlider.test.tsx create mode 100644 packages/react/src/ImageSlider/ImageSlider.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderContext.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderControls.test.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderControls.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderItem.test.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderItem.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderScroller.test.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderScroller.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderThumbnails.test.tsx create mode 100644 packages/react/src/ImageSlider/ImageSliderThumbnails.tsx create mode 100644 packages/react/src/ImageSlider/README.md create mode 100644 packages/react/src/ImageSlider/index.ts create mode 100644 proprietary/tokens/src/components/ams/image-slider.tokens.json create mode 100644 storybook/src/components/ImageSlider/ImageSlider.docs.mdx create mode 100644 storybook/src/components/ImageSlider/ImageSlider.stories.tsx diff --git a/packages/css/src/components/image-slider/README.md b/packages/css/src/components/image-slider/README.md new file mode 100644 index 0000000000..89feff940a --- /dev/null +++ b/packages/css/src/components/image-slider/README.md @@ -0,0 +1,23 @@ + + +# Image Slider + +Displays a small set of images in a limited space. + +## Design + +The first or selected image shows at its maximum size. +Every image displays a thumbnail at about 20% of its width. +Users can use buttons, thumbnails or swiping to navigate between the images. +The buttons re not displayed on a narrow touch device. +The images do not slide automatically. + +## How to use + +- Use this for a series of images that belong together. +- Feature the most essential image first. +- Display the Image Slider at the entire width of the [Screen](/docs/components-layout-screen--docs); do not position it on the [Grid](/docs/components-layout-grid--docs). +- Provide at least 2 images but at most 5. +- Assume that some or many users will not use the Slider and only see the first image. + Display all images separately if you want each of them to receive attention. +- Consult the [Image](/docs/components-media-image--docs) docs for guidelines on the individual images. diff --git a/packages/css/src/components/image-slider/image-slider.scss b/packages/css/src/components/image-slider/image-slider.scss new file mode 100644 index 0000000000..36a87b3aa7 --- /dev/null +++ b/packages/css/src/components/image-slider/image-slider.scss @@ -0,0 +1,89 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ +@import "../../common/breakpoint"; + +.ams-image-slider { + display: grid; + gap: var(--ams-image-slider-gap); + grid-template-rows: 1fr auto; +} + +.ams-image-slider__item { + scroll-snap-align: center; + scroll-snap-stop: always; + + /** temporary fix for covering the entire gallery */ + .ams-image { + inline-size: 100%; + } +} + +.ams-image-slider__scroller { + align-items: center; + display: grid; + gap: var(--ams-image-slider-scroller-gap); + grid-auto-columns: 100%; + grid-auto-flow: column; + grid-column: 1/-1; + grid-row: 1; + outline-offset: var(--ams-image-slider-scroller-outline-offset); + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x mandatory; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @media not (prefers-reduced-motion) { + scroll-behavior: smooth; + } +} + +.ams-image-slider__controls { + display: flex; + grid-column: 1/-1; + grid-row: 1; + justify-content: space-between; + + @media (pointer: coarse) and (max-width: $ams-breakpoint-medium) { + display: none; + } +} + +.ams-image-slider__control { + place-self: center; + z-index: 1; +} + +.ams-image-slider__thumbnails { + display: grid; + gap: var(--ams-image-slider-thumbnails-gap); + grid-template-columns: repeat(5, 1fr); + max-inline-size: 100%; +} + +.ams-image-slider__thumbnail { + background-color: var(--ams-image-slider-thumbnails-thumbnail-background-color); + background-position: center; + background-size: cover; + border: none; + cursor: var(--ams-image-slider-thumbnails-thumbnail-cursor); + opacity: var(--ams-image-slider-thumbnails-thumbnail-opacity); + outline-offset: var(--ams-button-outline-offset); + padding-block: 0; + padding-inline: 0; + position: relative; + scroll-snap-align: start; + + &:hover { + opacity: var(--ams-image-slider-thumbnails-thumbnail-hover-opacity); + } +} + +.ams-image-slider__thumbnail--in-view { + opacity: var(--ams-image-slider-thumbnails-thumbnail-in-view-opacity); +} diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index f23acbf3cf..995f0179fe 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -9,6 +9,7 @@ @import "./hint/hint"; @import "./password-input/password-input"; @import "./form-error-list/form-error-list"; +@import "./image-slider/image-slider"; @import "./table-of-contents/table-of-contents"; @import "./error-message/error-message"; @import "./file-input/file-input"; diff --git a/packages/css/src/components/screen/README.md b/packages/css/src/components/screen/README.md index 29991d61c0..8cc60f259d 100644 --- a/packages/css/src/components/screen/README.md +++ b/packages/css/src/components/screen/README.md @@ -12,6 +12,7 @@ Manages the maximum width and alignment of the entire website or application. [Header](/docs/components-containers-header--docs), [Footer](/docs/components-containers-footer--docs), [Spotlight](/docs/components-containers-spotlight--docs), + [Image Slider](/docs/components-containers-spotlight--docs), and Figure. ## Design diff --git a/packages/css/src/components/spotlight/README.md b/packages/css/src/components/spotlight/README.md index 05a0b0e545..a2eaf53cb0 100644 --- a/packages/css/src/components/spotlight/README.md +++ b/packages/css/src/components/spotlight/README.md @@ -6,6 +6,7 @@ Emphasizes a section on a page through a distinctive background colour. ## Guidelines +- Display the Spotlight at the entire width of the [Screen](/docs/components-layout-screen--docs); do not position it on the [Grid](/docs/components-layout-grid--docs). - Refer to [this overview on Stijlweb](https://amsterdam.nl/stijlweb/basiselementen/kleuren/#PagCls_15671872) to determine whether you can use black or white text on the background colour of your choice. ## Relevant WCAG requirements diff --git a/packages/react/src/ImageSlider/ImageSlider.test.tsx b/packages/react/src/ImageSlider/ImageSlider.test.tsx new file mode 100644 index 0000000000..89f7cd1ffc --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSlider.test.tsx @@ -0,0 +1,89 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { ImageSlider, ImageSliderImageProps } from './ImageSlider' +import '@testing-library/jest-dom' + +const observe = jest.fn() +const unobserve = jest.fn() +const disconnect = jest.fn() +const takeRecords = jest.fn() + +// Mock implementation of IntersectionObserver +window.IntersectionObserver = jest.fn(() => ({ + observe, + unobserve, + disconnect, + takeRecords, + root: null, + rootMargin: '', + thresholds: [], +})) + +describe('Image Slider', () => { + const images: ImageSliderImageProps[] = [ + { + alt: 'Bridge', + aspectRatio: 'x-wide', + src: 'https://picsum.photos/id/122/320/180', + }, + { + alt: 'Bunker', + aspectRatio: 'x-wide', + src: 'https://picsum.photos/id/101/320/180', + }, + { + alt: 'Chairs', + aspectRatio: 'x-wide', + src: 'https://picsum.photos/id/153/320/180', + }, + ] + + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders slides', () => { + const { container } = render() + + const slides = Array.from(container.querySelectorAll('.ams-image-slider__item')) + + expect(slides).toHaveLength(3) + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) + + it('renders thumbnails', () => { + const { container } = render() + + expect(container.querySelector('.ams-image-slider__thumbnails')).toBeInTheDocument() + }) +}) diff --git a/packages/react/src/ImageSlider/ImageSlider.tsx b/packages/react/src/ImageSlider/ImageSlider.tsx new file mode 100644 index 0000000000..a3181dfd8a --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSlider.tsx @@ -0,0 +1,188 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ForwardedRef, HTMLAttributes } from 'react' +import { ImageSliderContext } from './ImageSliderContext' +import { ImageSliderControls } from './ImageSliderControls' +import { ImageSliderItem } from './ImageSliderItem' +import { ImageSliderScroller } from './ImageSliderScroller' +import { ImageSliderThumbnails } from './ImageSliderThumbnails' +import { Ratio } from '../AspectRatio' +import { Image, ImageProps } from '../Image/Image' + +export type ImageSliderImageProps = ImageProps & { + /** Specify the aspect ratio to use for the images. */ + aspectRatio: Ratio +} + +export type ImageSliderProps = { + /** Display buttons to navigate to the previous or next image. */ + controls?: boolean + /** Label for the image if you need to translate the alt text. */ + imageLabel?: string + /** The set of images to display. */ + images: ImageSliderImageProps[] + /** The label for the ‘next’ button */ + nextLabel?: string + /** The label for the ‘previous’ button */ + previousLabel?: string +} & HTMLAttributes + +export const ImageSliderRoot = forwardRef( + ( + { + className, + controls, + imageLabel = 'Afbeelding', + images, + nextLabel = 'Volgende', + previousLabel = 'Vorige', + ...restProps + }: ImageSliderProps, + ref: ForwardedRef, + ) => { + const [currentSlideId, setCurrentSlideId] = useState(0) + const [isAtStart, setIsAtStart] = useState(true) + const [isAtEnd, setIsAtEnd] = useState(false) + const targetRef = useRef(null) + const observerRef = useRef(null) + + const inView = useCallback((observations: IntersectionObserverEntry[]) => { + const images = Array.from(targetRef.current?.children || []) + + observations.forEach((observation) => { + if (observation.isIntersecting) { + setCurrentSlideId(images.indexOf(observation.target as HTMLElement)) + } + }) + }, []) + + const observerOptions = useMemo( + () => ({ + root: targetRef.current, + threshold: 0.6, + }), + [], + ) + + const updateControls = useCallback(() => { + const sliderScrollerElement = targetRef.current + if (!sliderScrollerElement) return + + const { lastElementChild: lastElement, firstElementChild: firstElement } = sliderScrollerElement as HTMLDivElement + + setIsAtStart(firstElement === sliderScrollerElement?.children[currentSlideId]) + setIsAtEnd(lastElement === sliderScrollerElement?.children[currentSlideId]) + }, [currentSlideId]) + + useEffect(() => { + if (targetRef.current) { + observerRef.current = new IntersectionObserver(inView, observerOptions) + const observer = observerRef.current + + const slides = Array.from(targetRef.current.children) + slides.forEach((slide) => observer.observe(slide)) + + targetRef.current.addEventListener('scrollend', synchronise) + + updateControls() + + return () => { + slides.forEach((slide) => observer.unobserve(slide)) + targetRef.current?.removeEventListener('scrollend', synchronise) + } + } + + return undefined + }, [inView, observerOptions, updateControls]) + + const synchronise = useCallback(() => updateControls(), [updateControls]) + + const goToSlide = useCallback((element: HTMLElement) => { + const sliderScrollerElement = targetRef.current + if (!sliderScrollerElement || !element) return + + sliderScrollerElement.scrollTo({ + left: sliderScrollerElement.scrollLeft + element.offsetLeft - sliderScrollerElement.scrollLeft, + }) + }, []) + + const goToSlideId = useCallback( + (id: number) => { + const element = targetRef.current?.children[id] as HTMLElement | null + if (element) goToSlide(element) + }, + [goToSlide], + ) + + const goToNextSlide = useCallback(() => { + const element = targetRef.current?.children[currentSlideId] + const nextElement = element?.nextElementSibling as HTMLElement | null + + if (nextElement) goToSlide(nextElement) + }, [currentSlideId, goToSlide]) + + const goToPreviousSlide = useCallback(() => { + const element = targetRef.current?.children[currentSlideId] + const previousElement = element?.previousElementSibling as HTMLElement | null + + if (previousElement) goToSlide(previousElement) + }, [currentSlideId, goToSlide]) + + useEffect(() => { + const handleResize = () => { + const sliderScrollerElement = targetRef.current + const currentSlideElement = targetRef.current?.children[currentSlideId] as HTMLElement | null + + if (!sliderScrollerElement || !currentSlideElement) return + + const expectedScrollLeft = currentSlideElement.offsetLeft + + if (Math.abs(sliderScrollerElement.scrollLeft - expectedScrollLeft) < 1) return + + goToSlide(currentSlideElement) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [currentSlideId, goToSlide]) + + return ( + +
+ {controls && } + + {images.map(({ alt, aspectRatio, sizes, src, srcSet }, index) => ( + + {alt} + + ))} + + +
+
+ ) + }, +) + +ImageSliderRoot.displayName = 'ImageSlider' + +export const ImageSlider = Object.assign(ImageSliderRoot, { Item: ImageSliderItem }) diff --git a/packages/react/src/ImageSlider/ImageSliderContext.tsx b/packages/react/src/ImageSlider/ImageSliderContext.tsx new file mode 100644 index 0000000000..f20148ccd6 --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderContext.tsx @@ -0,0 +1,27 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { createContext } from 'react' + +export type ImageSliderContextValue = { + currentSlideId: number + isAtStart: boolean + isAtEnd: boolean + goToNextSlide: () => void + goToPreviousSlide: () => void + // eslint-disable-next-line no-unused-vars + goToSlideId: (id: number) => void +} + +const defaultValues: ImageSliderContextValue = { + currentSlideId: 0, + isAtStart: true, + isAtEnd: false, + goToNextSlide: () => {}, + goToPreviousSlide: () => {}, + goToSlideId: () => {}, +} + +export const ImageSliderContext = createContext(defaultValues) diff --git a/packages/react/src/ImageSlider/ImageSliderControls.test.tsx b/packages/react/src/ImageSlider/ImageSliderControls.test.tsx new file mode 100644 index 0000000000..c267249965 --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderControls.test.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { ImageSliderControls } from './ImageSliderControls' +import '@testing-library/jest-dom' + +describe('Image Slider Controls', () => { + const nextLabel = 'Volgende' + const previousLabel = 'Vorige' + + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/ImageSlider/ImageSliderControls.tsx b/packages/react/src/ImageSlider/ImageSliderControls.tsx new file mode 100644 index 0000000000..38b8ef3e61 --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderControls.tsx @@ -0,0 +1,53 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { ChevronLeftIcon, ChevronRightIcon } from '@amsterdam/design-system-react-icons' +import clsx from 'clsx' +import { forwardRef, useCallback, useContext } from 'react' +import type { ForwardedRef, HTMLAttributes } from 'react' +import { ImageSliderContext } from './ImageSliderContext' +import { IconButton } from '../IconButton' + +export type ImageSliderControlsProps = { + /** The label for the ‘next’ button */ + nextLabel: string + /** The label for the ‘previous’ button */ + previousLabel: string +} & HTMLAttributes + +export const ImageSliderControls = forwardRef( + ( + { className, nextLabel, previousLabel, ...restProps }: ImageSliderControlsProps, + ref: ForwardedRef, + ) => { + const { isAtStart, isAtEnd, goToNextSlide, goToPreviousSlide } = useContext(ImageSliderContext) + + const handleClickPrevious = useCallback(() => goToPreviousSlide(), [goToPreviousSlide]) + const handleClickNext = useCallback(() => goToNextSlide(), [goToNextSlide]) + + return ( +
+ + +
+ ) + }, +) + +ImageSliderControls.displayName = 'ImageSliderControls' diff --git a/packages/react/src/ImageSlider/ImageSliderItem.test.tsx b/packages/react/src/ImageSlider/ImageSliderItem.test.tsx new file mode 100644 index 0000000000..e9e4f1fc54 --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderItem.test.tsx @@ -0,0 +1,47 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { ImageSliderItem } from './ImageSliderItem' +import '@testing-library/jest-dom' + +describe('Image Slider Item', () => { + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders children', () => { + const { container } = render(child) + + expect(container).toHaveTextContent('child') + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider__item') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider__item extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/ImageSlider/ImageSliderItem.tsx b/packages/react/src/ImageSlider/ImageSliderItem.tsx new file mode 100644 index 0000000000..eb94232d8c --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderItem.tsx @@ -0,0 +1,34 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef, useContext, useMemo } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' +import { ImageSliderContext } from './ImageSliderContext' + +export type ImageSliderItemProps = { + /** The identifier of the item. Must match the key or order of the slides (starting at 0). */ + slideId: number +} & PropsWithChildren> + +export const ImageSliderItem = forwardRef( + ({ children, slideId, className, ...restProps }: ImageSliderItemProps, ref: ForwardedRef) => { + const { currentSlideId } = useContext(ImageSliderContext) + + const isInView = useMemo(() => currentSlideId === slideId, [currentSlideId, slideId]) + const itemClassName = useMemo( + () => clsx('ams-image-slider__item', isInView && 'ams-image-slider__item--in-view', className), + [isInView, className], + ) + + return ( +
+ {children} +
+ ) + }, +) + +ImageSliderItem.displayName = 'ImageSlider.Item' diff --git a/packages/react/src/ImageSlider/ImageSliderScroller.test.tsx b/packages/react/src/ImageSlider/ImageSliderScroller.test.tsx new file mode 100644 index 0000000000..320d5fb1d0 --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderScroller.test.tsx @@ -0,0 +1,47 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { ImageSliderScroller } from './ImageSliderScroller' +import '@testing-library/jest-dom' + +describe('Image Slider Scroller', () => { + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders children', () => { + const { container } = render(child) + + expect(container).toHaveTextContent('child') + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider__scroller') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider__scroller extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/ImageSlider/ImageSliderScroller.tsx b/packages/react/src/ImageSlider/ImageSliderScroller.tsx new file mode 100644 index 0000000000..4203de09a4 --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderScroller.tsx @@ -0,0 +1,20 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' + +export type ImageSliderScrollerProps = PropsWithChildren> + +export const ImageSliderScroller = forwardRef( + ({ children, className, ...restProps }: ImageSliderScrollerProps, ref: ForwardedRef) => ( +
+ {children} +
+ ), +) + +ImageSliderScroller.displayName = 'ImageSlider.Scroller' diff --git a/packages/react/src/ImageSlider/ImageSliderThumbnails.test.tsx b/packages/react/src/ImageSlider/ImageSliderThumbnails.test.tsx new file mode 100644 index 0000000000..c41c87303b --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderThumbnails.test.tsx @@ -0,0 +1,68 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { ImageSliderImageProps } from './ImageSlider' +import { ImageSliderThumbnails } from './ImageSliderThumbnails' +import '@testing-library/jest-dom' + +describe('Image Slider Thumbnails', () => { + const thumbnails: ImageSliderImageProps[] = [ + { + alt: 'This is gallery image 1', + aspectRatio: 'x-wide', + src: 'https://picsum.photos/id/122/1280/720', + }, + { + alt: 'This is gallery image 2', + aspectRatio: 'x-wide', + src: 'https://picsum.photos/id/101/1280/720', + }, + { + alt: 'This is gallery image 3', + aspectRatio: 'x-wide', + src: 'https://picsum.photos/id/153/1280/720', + }, + ] + + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders thumbnails', () => { + const { container } = render() + + const thumbs = container.querySelectorAll('.ams-image-slider__thumbnail') + + expect(thumbs).toHaveLength(thumbnails.length) + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider__thumbnails') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-image-slider__thumbnails extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/ImageSlider/ImageSliderThumbnails.tsx b/packages/react/src/ImageSlider/ImageSliderThumbnails.tsx new file mode 100644 index 0000000000..a634c2dfdc --- /dev/null +++ b/packages/react/src/ImageSlider/ImageSliderThumbnails.tsx @@ -0,0 +1,83 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef, KeyboardEvent, useCallback, useContext, useMemo } from 'react' +import type { ForwardedRef, HTMLAttributes } from 'react' +import { ImageSliderImageProps } from './ImageSlider' +import { ImageSliderContext } from './ImageSliderContext' + +export type ImageSliderThumbnailsProps = { + imageLabel?: string + thumbnails: ImageSliderImageProps[] +} & HTMLAttributes + +export const ImageSliderThumbnails = forwardRef( + ({ className, imageLabel, thumbnails, ...restProps }: ImageSliderThumbnailsProps, ref: ForwardedRef) => { + const { currentSlideId, goToNextSlide, goToPreviousSlide, goToSlideId } = useContext(ImageSliderContext) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const element = event.currentTarget.children[currentSlideId] + + if (event.key === 'ArrowRight') { + const nextElement = element?.nextElementSibling as HTMLElement | null + + if (nextElement) { + nextElement.focus() + goToNextSlide() + } + } + + if (event.key === 'ArrowLeft') { + const previousElement = element?.previousElementSibling as HTMLElement | null + + if (previousElement) { + previousElement.focus() + goToPreviousSlide() + } + } + }, + [currentSlideId, goToNextSlide, goToPreviousSlide], + ) + + const renderThumbnails = useMemo( + () => + thumbnails.map(({ alt, aspectRatio, src }, index) => ( +