@lumx/react 3.8.1 → 3.8.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -6,8 +6,8 @@
6
6
  "url": "https://github.com/lumapps/design-system/issues"
7
7
  },
8
8
  "dependencies": {
9
- "@lumx/core": "^3.8.1",
10
- "@lumx/icons": "^3.8.1",
9
+ "@lumx/core": "^3.8.2-alpha.1",
10
+ "@lumx/icons": "^3.8.2-alpha.1",
11
11
  "@popperjs/core": "^2.5.4",
12
12
  "body-scroll-lock": "^3.1.5",
13
13
  "classnames": "^2.3.2",
@@ -111,5 +111,5 @@
111
111
  "build:storybook": "storybook build"
112
112
  },
113
113
  "sideEffects": false,
114
- "version": "3.8.1"
114
+ "version": "3.8.2-alpha.1"
115
115
  }
@@ -0,0 +1,165 @@
1
+ import React from 'react';
2
+ import { IMAGES } from '@lumx/react/stories/controls/image';
3
+ import { Button, Mosaic, ImageLightbox, ImageLightboxProps, Chip, ChipGroup } from '@lumx/react';
4
+ import { withWrapper } from '@lumx/react/stories/decorators/withWrapper';
5
+
6
+ const ZOOM_PROPS = {
7
+ zoomInButtonProps: { label: 'Zoom in' },
8
+ zoomOutButtonProps: { label: 'Zoom out' },
9
+ };
10
+
11
+ const SLIDESHOW_PROPS = {
12
+ slideshowControlsProps: {
13
+ nextButtonProps: { label: 'Next' },
14
+ previousButtonProps: { label: 'Previous' },
15
+ paginationItemProps: (index: number) => ({ label: `Go to slide ${index + 1}` }),
16
+ },
17
+ };
18
+
19
+ const MULTIPLE_IMAGES: ImageLightboxProps['images'] = [
20
+ {
21
+ image: 'https://picsum.photos/id/237/2000/3000',
22
+ alt: 'Image 1',
23
+ title: 'Little puppy',
24
+ description: 'A black labrador puppy with big brown eyes, looking up with a curious and innocent expression.',
25
+ tags: (
26
+ <ChipGroup>
27
+ <Chip theme="dark" size="s">
28
+ Tag 1
29
+ </Chip>
30
+ <Chip theme="dark" size="s">
31
+ Tag 2
32
+ </Chip>
33
+ </ChipGroup>
34
+ ),
35
+ },
36
+ {
37
+ image: 'https://picsum.photos/id/337/3000/1000',
38
+ alt: 'Image 2',
39
+ // Intentionally using the wrong size to see how it renders while loading the image
40
+ imgProps: { width: '300px', height: '100px' },
41
+ },
42
+ {
43
+ image: 'https://picsum.photos/id/437/2000/2000',
44
+ alt: 'Image 3',
45
+ },
46
+ {
47
+ image: 'https://picsum.photos/id/537/300/400',
48
+ alt: 'Image 4',
49
+ },
50
+ {
51
+ image: 'https://picsum.photos/id/637/400/200',
52
+ alt: 'Image 5',
53
+ },
54
+ {
55
+ image: 'https://picsum.photos/id/737/300/300',
56
+ alt: 'Image 6',
57
+ },
58
+ ];
59
+
60
+ export default {
61
+ title: 'LumX components/image-lightbox/ImageLightbox',
62
+ component: ImageLightbox,
63
+ args: {
64
+ closeButtonProps: { label: 'Close' },
65
+ },
66
+ argTypes: {
67
+ onClose: { action: true },
68
+ },
69
+ };
70
+
71
+ /**
72
+ * Display a single image fullscreen in the ImageLightbox
73
+ */
74
+ export const SingleImage = {
75
+ args: {
76
+ images: [{ image: IMAGES.portrait1s200, alt: 'Image 1' }],
77
+ isOpen: true,
78
+ },
79
+ };
80
+
81
+ /**
82
+ * Display a single image fullscreen in the ImageLightbox with zoom controls
83
+ */
84
+ export const SingleImageWithZoom = {
85
+ ...SingleImage,
86
+ args: { ...SingleImage.args, ...ZOOM_PROPS },
87
+ };
88
+
89
+ /**
90
+ * Display a single image fullscreen in the ImageLightbox with metadata (title, description, etc.)
91
+ */
92
+ export const SingleImageWithMetadata = {
93
+ ...SingleImage,
94
+ args: { ...SingleImage.args, images: [MULTIPLE_IMAGES[0]] },
95
+ };
96
+
97
+ /**
98
+ * Display a multiple image fullscreen in the ImageLightbox
99
+ */
100
+ export const MultipleImages = {
101
+ args: {
102
+ images: MULTIPLE_IMAGES,
103
+ isOpen: true,
104
+ ...SLIDESHOW_PROPS,
105
+ },
106
+ };
107
+
108
+ /**
109
+ * Display a multiple images fullscreen in the ImageLightbox with zoom controls
110
+ */
111
+ export const MultipleImagesWithZoom = {
112
+ ...MultipleImages,
113
+ args: { ...MultipleImages.args, ...ZOOM_PROPS },
114
+ };
115
+
116
+ /**
117
+ * Open ImageLightbox via buttons
118
+ */
119
+ export const WithButtonTrigger = {
120
+ decorators: [
121
+ (Story: any, { args }: any) => {
122
+ const { getTriggerProps, imageLightboxProps } = ImageLightbox.useImageLightbox({
123
+ images: [
124
+ { image: IMAGES.portrait1s200, alt: 'Image 1' },
125
+ { image: IMAGES.landscape1s200, alt: 'Image 2' },
126
+ ],
127
+ });
128
+ return (
129
+ <>
130
+ <Story args={{ ...args, ...imageLightboxProps }} />
131
+ <Button {...(getTriggerProps({ activeImageIndex: 0 }) as any)}>Image 1</Button>
132
+ <Button {...(getTriggerProps({ activeImageIndex: 1 }) as any)}>Image 2</Button>
133
+ </>
134
+ );
135
+ },
136
+ ],
137
+ // Disables Chromatic snapshot (not relevant for this story).
138
+ parameters: { chromatic: { disable: true } },
139
+ };
140
+
141
+ /**
142
+ * Open ImageLightbox with zoom and slideshow via clickable thumbnails in a Mosaic
143
+ */
144
+ export const WithMosaicTrigger = {
145
+ args: { ...SLIDESHOW_PROPS, ...ZOOM_PROPS },
146
+ decorators: [
147
+ (Story: any, { args }: any) => {
148
+ const { getTriggerProps, imageLightboxProps } = ImageLightbox.useImageLightbox({ images: MULTIPLE_IMAGES });
149
+ return (
150
+ <>
151
+ <Story args={{ ...args, ...imageLightboxProps }} />
152
+ <Mosaic
153
+ thumbnails={MULTIPLE_IMAGES.map((image, index) => ({
154
+ ...image,
155
+ ...getTriggerProps({ activeImageIndex: index }),
156
+ }))}
157
+ />
158
+ </>
159
+ );
160
+ },
161
+ withWrapper({ style: { width: 300 } }),
162
+ ],
163
+ // Disables Chromatic snapshot (not relevant for this story).
164
+ parameters: { chromatic: { disable: true } },
165
+ };
@@ -0,0 +1,253 @@
1
+ import React from 'react';
2
+
3
+ import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
4
+ import { render, within, screen } from '@testing-library/react';
5
+ import { getByClassName, queryByClassName } from '@lumx/react/testing/utils/queries';
6
+ import userEvent from '@testing-library/user-event';
7
+ import { useImageSize } from '@lumx/react/hooks/useImageSize';
8
+ import { useElementSizeDependentOfWindowSize } from '@lumx/react/hooks/useElementSizeDependentOfWindowSize';
9
+
10
+ import { ImageLightbox } from './ImageLightbox';
11
+ import { ImageLightboxProps } from './types';
12
+ import Meta, {
13
+ MultipleImages,
14
+ MultipleImagesWithZoom,
15
+ SingleImage,
16
+ SingleImageWithZoom,
17
+ WithButtonTrigger,
18
+ WithMosaicTrigger,
19
+ } from './ImageLightbox.stories';
20
+
21
+ jest.mock('@lumx/react/hooks/useImageSize');
22
+ jest.mock('@lumx/react/hooks/useElementSizeDependentOfWindowSize');
23
+
24
+ const CLASSNAME = ImageLightbox.className as string;
25
+ const baseProps = Meta.args;
26
+
27
+ const setup = (overrides: Partial<ImageLightboxProps> = {}) => {
28
+ const props: any = {
29
+ ...baseProps,
30
+ isOpen: true,
31
+ images: [],
32
+ ...overrides,
33
+ };
34
+ const result = render(<ImageLightbox {...props} />);
35
+ const rerender = () => result.rerender(<ImageLightbox {...props} />);
36
+ const imageLightbox = queryByClassName(document.body, CLASSNAME);
37
+ return { props, imageLightbox, rerender };
38
+ };
39
+
40
+ const queries = {
41
+ getImageLightbox: () => getByClassName(document.body, CLASSNAME),
42
+ queryCloseButton: (imageLightbox: HTMLElement) => within(imageLightbox).queryByRole('button', { name: 'Close' }),
43
+ queryImage: (imageLightbox: HTMLElement, name?: string) => within(imageLightbox).queryByRole('img', { name }),
44
+ queryScrollArea: (imageLightbox: HTMLElement) =>
45
+ queryByClassName(imageLightbox, 'lumx-image-lightbox__image-slide'),
46
+ queryZoomInButton: (imageLightbox: HTMLElement) => within(imageLightbox).queryByRole('button', { name: 'Zoom in' }),
47
+ queryZoomOutButton: (imageLightbox: HTMLElement) =>
48
+ within(imageLightbox).queryByRole('button', { name: 'Zoom out' }),
49
+ queryPrevSlideButton: (imageLightbox: HTMLElement) =>
50
+ within(imageLightbox).queryByRole('button', { name: 'Previous' }),
51
+ queryNextSlideButton: (imageLightbox: HTMLElement) => within(imageLightbox).queryByRole('button', { name: 'Next' }),
52
+ querySlideButton: (imageLightbox: HTMLElement, slide: number) =>
53
+ within(imageLightbox).queryByRole('tab', { name: `Go to slide ${slide}` }),
54
+ };
55
+
56
+ describe(`<${ImageLightbox.displayName}>`, () => {
57
+ beforeEach(() => {
58
+ (useImageSize as any).mockReturnValue(null);
59
+ (useElementSizeDependentOfWindowSize as any).mockReturnValue([null, jest.fn()]);
60
+ });
61
+
62
+ describe('render', () => {
63
+ it('should render single image', () => {
64
+ setup(SingleImage.args);
65
+ const imageLightbox = queries.getImageLightbox();
66
+
67
+ // Should render
68
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
69
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
70
+
71
+ // Should not render
72
+ expect(queries.queryZoomInButton(imageLightbox)).not.toBeInTheDocument();
73
+ expect(queries.queryZoomOutButton(imageLightbox)).not.toBeInTheDocument();
74
+ expect(queries.queryPrevSlideButton(imageLightbox)).not.toBeInTheDocument();
75
+ expect(queries.queryNextSlideButton(imageLightbox)).not.toBeInTheDocument();
76
+ });
77
+
78
+ it('should render single image with zoom', () => {
79
+ setup(SingleImageWithZoom.args);
80
+ const imageLightbox = queries.getImageLightbox();
81
+
82
+ // Should render
83
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
84
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
85
+ expect(queries.queryZoomInButton(imageLightbox)).toBeInTheDocument();
86
+ expect(queries.queryZoomOutButton(imageLightbox)).toBeInTheDocument();
87
+
88
+ // Should not render
89
+ expect(queries.queryPrevSlideButton(imageLightbox)).not.toBeInTheDocument();
90
+ expect(queries.queryNextSlideButton(imageLightbox)).not.toBeInTheDocument();
91
+ });
92
+
93
+ it('should render multiple images', () => {
94
+ setup(MultipleImages.args);
95
+ const imageLightbox = queries.getImageLightbox();
96
+
97
+ // Should render
98
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
99
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
100
+ expect(queries.queryPrevSlideButton(imageLightbox)).toBeInTheDocument();
101
+ expect(queries.queryNextSlideButton(imageLightbox)).toBeInTheDocument();
102
+
103
+ // Should not render
104
+ expect(queries.queryZoomInButton(imageLightbox)).not.toBeInTheDocument();
105
+ expect(queries.queryZoomOutButton(imageLightbox)).not.toBeInTheDocument();
106
+ });
107
+
108
+ it('should render multiple images with set active image', () => {
109
+ setup({ ...MultipleImages.args, activeImageIndex: 1 });
110
+ const imageLightbox = queries.getImageLightbox();
111
+
112
+ // Should render
113
+ expect(queries.queryImage(imageLightbox, 'Image 2')).toBeInTheDocument();
114
+ });
115
+
116
+ it('should render multiple images with zoom', () => {
117
+ setup(MultipleImagesWithZoom.args);
118
+ const imageLightbox = queries.getImageLightbox();
119
+
120
+ // Should render
121
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
122
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
123
+ expect(queries.queryPrevSlideButton(imageLightbox)).toBeInTheDocument();
124
+ expect(queries.queryNextSlideButton(imageLightbox)).toBeInTheDocument();
125
+ expect(queries.queryZoomInButton(imageLightbox)).toBeInTheDocument();
126
+ expect(queries.queryZoomOutButton(imageLightbox)).toBeInTheDocument();
127
+ });
128
+ });
129
+
130
+ describe('trigger', () => {
131
+ it('should move focus on open and close with single image lightbox', async () => {
132
+ const decorator = WithButtonTrigger.decorators[0];
133
+ const Story = ({ args }: any) => <ImageLightbox {...args} />;
134
+ const Render = () => decorator(Story, { args: baseProps });
135
+ render(<Render />);
136
+
137
+ // Focus the second button
138
+ await userEvent.tab();
139
+ await userEvent.tab();
140
+ const buttonTrigger = screen.getByRole('button', { name: 'Image 2' });
141
+ expect(buttonTrigger).toHaveFocus();
142
+
143
+ // Open image lightbox with the button trigger
144
+ await userEvent.keyboard('{enter}');
145
+
146
+ // Focus moved to the close button
147
+ const imageLightbox = queries.getImageLightbox();
148
+ expect(queries.queryCloseButton(imageLightbox)).toHaveFocus();
149
+
150
+ // Image lightbox opened on the correct image
151
+ expect(queries.queryImage(imageLightbox, 'Image 2')).toBeInTheDocument();
152
+
153
+ // Close on escape
154
+ await userEvent.keyboard('{escape}');
155
+ expect(imageLightbox).not.toBeInTheDocument();
156
+
157
+ // Focus moved back to the trigger button
158
+ expect(buttonTrigger).toHaveFocus();
159
+ });
160
+
161
+ it('should move focus on open and close with multiple image lightbox', async () => {
162
+ const decorator = WithMosaicTrigger.decorators[0];
163
+ const Story = ({ args }: any) => <ImageLightbox {...args} />;
164
+ const Render = () => decorator(Story, { args: { ...baseProps, ...WithMosaicTrigger.args } });
165
+ render(<Render />);
166
+
167
+ // Focus the first button & activate to open
168
+ await userEvent.tab();
169
+ const buttonTrigger = document.activeElement;
170
+ await userEvent.keyboard('{enter}');
171
+
172
+ // Focus moved to the first slide button
173
+ const imageLightbox = queries.getImageLightbox();
174
+ expect(queries.querySlideButton(imageLightbox, 1)).toHaveFocus();
175
+
176
+ // Image lightbox opened on the correct image
177
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
178
+
179
+ // Close on escape
180
+ await userEvent.keyboard('{escape}');
181
+ expect(imageLightbox).not.toBeInTheDocument();
182
+
183
+ // Focus moved back to the trigger button
184
+ expect(buttonTrigger).toHaveFocus();
185
+ });
186
+ });
187
+
188
+ describe('zoom', () => {
189
+ const scrollAreaSize = { width: 600, height: 600 };
190
+ beforeEach(() => {
191
+ (useImageSize as any).mockImplementation((_: any, getInitialSize: any) => getInitialSize?.() || null);
192
+ (useElementSizeDependentOfWindowSize as any).mockReturnValue([scrollAreaSize, jest.fn()]);
193
+ });
194
+
195
+ it('should use the image initial size', () => {
196
+ setup({
197
+ images: [
198
+ { image: 'https://example.com/image.png', alt: 'Image 1', imgProps: { width: 200, height: 200 } },
199
+ ],
200
+ });
201
+ const imageLightbox = queries.getImageLightbox();
202
+ const image = queries.queryImage(imageLightbox, 'Image 1');
203
+ expect(image).toHaveStyle({
204
+ height: `200px`,
205
+ width: `200px`,
206
+ });
207
+ });
208
+
209
+ it('should zoom on zoom button pressed', async () => {
210
+ const { rerender } = setup(SingleImageWithZoom.args);
211
+ const imageLightbox = queries.getImageLightbox();
212
+
213
+ // Initial image style
214
+ const image = queries.queryImage(imageLightbox, 'Image 1');
215
+
216
+ expect(image).toHaveStyle({
217
+ maxHeight: `${scrollAreaSize.height}px`,
218
+ maxWidth: `${scrollAreaSize.width}px`,
219
+ });
220
+
221
+ // Update image size (simulate image loaded)
222
+ const imageSize = { width: 500, height: 300 };
223
+ (useImageSize as any).mockReturnValue(imageSize);
224
+ rerender();
225
+
226
+ // Image style updated
227
+ expect(image).toHaveStyle({ width: `${imageSize.width}px`, height: `${imageSize.height}px` });
228
+
229
+ // Scroll area is bigger than the image, it should not be focusable
230
+ expect(queries.queryScrollArea(imageLightbox)).not.toHaveAttribute('tabindex');
231
+
232
+ // Zoom in
233
+ const zoomInButton = queries.queryZoomInButton(imageLightbox) as any;
234
+ await userEvent.click(zoomInButton);
235
+ expect(image).toHaveStyle({ height: '450px', width: '750px' });
236
+
237
+ // Scroll area is smaller than the image, it should be focusable
238
+ expect(queries.queryScrollArea(imageLightbox)).toHaveAttribute('tabindex', '0');
239
+
240
+ // Zoom out
241
+ const zoomOutButton = queries.queryZoomOutButton(imageLightbox) as any;
242
+ await userEvent.click(zoomOutButton);
243
+ expect(image).toHaveStyle({ width: `${imageSize.width}px`, height: `${imageSize.height}px` });
244
+ });
245
+ });
246
+
247
+ // Common tests suite.
248
+ commonTestsSuiteRTL(setup, {
249
+ baseClassName: CLASSNAME,
250
+ forwardClassName: 'imageLightbox',
251
+ forwardAttributes: 'imageLightbox',
252
+ });
253
+ });
@@ -0,0 +1,85 @@
1
+ import React, { forwardRef } from 'react';
2
+
3
+ import classNames from 'classnames';
4
+ import { Lightbox } from '@lumx/react';
5
+ import { ClickAwayProvider } from '@lumx/react/utils';
6
+ import type { Comp } from '@lumx/react/utils/type';
7
+ import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
8
+
9
+ import { ImageSlideshow } from './internal/ImageSlideshow';
10
+ import { useImageLightbox } from './useImageLightbox';
11
+ import type { ImageLightboxProps } from './types';
12
+ import { CLASSNAME, COMPONENT_NAME } from './constants';
13
+
14
+ const Inner: Comp<ImageLightboxProps, HTMLDivElement> = forwardRef((props, ref) => {
15
+ const {
16
+ className,
17
+ isOpen,
18
+ closeButtonProps,
19
+ onClose,
20
+ parentElement,
21
+ activeImageIndex,
22
+ slideshowControlsProps,
23
+ slideGroupLabel,
24
+ images,
25
+ zoomOutButtonProps,
26
+ zoomInButtonProps,
27
+ activeImageRef: propImageRef,
28
+ ...forwardedProps
29
+ } = props;
30
+ const currentPaginationItemRef = React.useRef(null);
31
+ const footerRef = React.useRef(null);
32
+ const imageRef = React.useRef(null);
33
+ const clickAwayChildrenRefs = React.useRef([imageRef, footerRef]);
34
+
35
+ const onClickAway = React.useCallback(
36
+ (evt: Event) => {
37
+ const targetElement = evt.target;
38
+ if (!(targetElement instanceof HTMLElement) || !(evt instanceof MouseEvent)) return;
39
+
40
+ // Skip click away if clicking on the scrollbar
41
+ if (targetElement.clientWidth < evt.clientX || targetElement.clientHeight < evt.clientY) return;
42
+
43
+ onClose?.();
44
+ },
45
+ [onClose],
46
+ );
47
+
48
+ return (
49
+ <Lightbox
50
+ ref={ref}
51
+ className={classNames(className, CLASSNAME)}
52
+ parentElement={parentElement}
53
+ isOpen={isOpen}
54
+ onClose={onClose}
55
+ closeButtonProps={closeButtonProps}
56
+ focusElement={currentPaginationItemRef}
57
+ {...forwardedProps}
58
+ >
59
+ <ClickAwayProvider childrenRefs={clickAwayChildrenRefs} callback={onClickAway}>
60
+ <ImageSlideshow
61
+ activeImageIndex={activeImageIndex}
62
+ slideGroupLabel={slideGroupLabel}
63
+ slideshowControlsProps={slideshowControlsProps}
64
+ images={images}
65
+ zoomInButtonProps={zoomInButtonProps}
66
+ zoomOutButtonProps={zoomOutButtonProps}
67
+ footerRef={footerRef}
68
+ activeImageRef={useMergeRefs(propImageRef, imageRef)}
69
+ currentPaginationItemRef={currentPaginationItemRef}
70
+ />
71
+ </ClickAwayProvider>
72
+ </Lightbox>
73
+ );
74
+ });
75
+ Inner.displayName = COMPONENT_NAME;
76
+ Inner.className = CLASSNAME;
77
+
78
+ /**
79
+ * ImageLightbox component.
80
+ *
81
+ * @param props Component props.
82
+ * @param ref Component ref.
83
+ * @return React element.
84
+ */
85
+ export const ImageLightbox = Object.assign(Inner, { useImageLightbox });
@@ -0,0 +1,11 @@
1
+ import { getRootClassName } from '@lumx/react/utils/className';
2
+
3
+ /**
4
+ * Component display name.
5
+ */
6
+ export const COMPONENT_NAME = 'ImageLightbox';
7
+
8
+ /**
9
+ * Component default class name and class prefix.
10
+ */
11
+ export const CLASSNAME = getRootClassName(COMPONENT_NAME);
@@ -0,0 +1,2 @@
1
+ export { ImageLightbox } from './ImageLightbox';
2
+ export type { ImageLightboxProps } from './types';
@@ -0,0 +1,107 @@
1
+ import React from 'react';
2
+
3
+ import { SlideshowItem, Thumbnail } from '@lumx/react';
4
+ import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
5
+ import { useElementSizeDependentOfWindowSize } from '@lumx/react/hooks/useElementSizeDependentOfWindowSize';
6
+ import { useImageSize } from '@lumx/react/hooks/useImageSize';
7
+ import { getPrefersReducedMotion } from '@lumx/react/utils/browser/getPrefersReducedMotion';
8
+ import { isEqual } from '@lumx/react/utils/object/isEqual';
9
+
10
+ import { CLASSNAME } from '../constants';
11
+ import { usePointerZoom } from './usePointerZoom';
12
+ import { useAnimateScroll } from './useAnimateScroll';
13
+ import type { ImageProps } from '../types';
14
+
15
+ export interface ImageSlideProps {
16
+ image: ImageProps;
17
+ isActive?: boolean;
18
+ scale?: number;
19
+ onScaleChange?: (value: number) => void;
20
+ }
21
+
22
+ /** Internal image slide component for ImageLightbox */
23
+ export const ImageSlide = React.memo((props: ImageSlideProps) => {
24
+ const {
25
+ isActive,
26
+ scale,
27
+ onScaleChange,
28
+ image: { image, imgRef: propImgRef, imgProps, alt, loadingPlaceholderImageRef },
29
+ } = props;
30
+
31
+ // Get scroll area size
32
+ const scrollAreaRef = React.useRef<HTMLDivElement>(null);
33
+ const [scrollAreaSize, updateSize] = useElementSizeDependentOfWindowSize(scrollAreaRef);
34
+ React.useEffect(() => {
35
+ // Update size when active
36
+ if (isActive) updateSize();
37
+ }, [isActive, updateSize]);
38
+
39
+ // Get image size
40
+ const imgRef = React.useRef<HTMLImageElement>(null);
41
+ const imageSize = useImageSize(imgRef, () => {
42
+ const width = Number.parseInt(imgProps?.width as any, 10);
43
+ const height = Number.parseInt(imgProps?.height as any, 10);
44
+ return width && height ? { width, height } : null;
45
+ });
46
+
47
+ // Calculate new image size with scale
48
+ const scaledImageSize = React.useMemo(() => {
49
+ if (!scrollAreaSize || !imageSize) {
50
+ return null;
51
+ }
52
+ const horizontalScale = scrollAreaSize.width / imageSize.width;
53
+ const verticalScale = scrollAreaSize.height / imageSize.height;
54
+ const baseScale = Math.min(1, Math.min(horizontalScale, verticalScale));
55
+ return {
56
+ width: imageSize.width * baseScale * (scale ?? 1),
57
+ height: imageSize.height * baseScale * (scale ?? 1),
58
+ };
59
+ }, [scrollAreaSize, imageSize, scale]);
60
+
61
+ // Animate scroll to preserve the center of the current visible window in the scroll area
62
+ const animateScroll = useAnimateScroll(scrollAreaRef);
63
+
64
+ // Zoom via mouse wheel or multi-touch pinch zoom
65
+ const isPointerZooming = usePointerZoom(scrollAreaRef, onScaleChange, animateScroll);
66
+
67
+ // Animate scroll on scale change
68
+ React.useLayoutEffect(() => {
69
+ if (scale && !isPointerZooming) {
70
+ animateScroll();
71
+ }
72
+ }, [isPointerZooming, scale, animateScroll]);
73
+
74
+ const isScrollable =
75
+ scaledImageSize &&
76
+ scrollAreaSize &&
77
+ (scaledImageSize.width > scrollAreaSize.width || scaledImageSize.height > scrollAreaSize.height);
78
+
79
+ return (
80
+ <SlideshowItem
81
+ ref={scrollAreaRef}
82
+ // Make it accessible to keyboard nav when the zone is scrollable
83
+ tabIndex={isScrollable ? 0 : undefined}
84
+ className={`${CLASSNAME}__image-slide`}
85
+ >
86
+ <Thumbnail
87
+ imgRef={useMergeRefs(imgRef, propImgRef)}
88
+ image={image}
89
+ alt={alt}
90
+ className={`${CLASSNAME}__thumbnail`}
91
+ imgProps={{
92
+ ...imgProps,
93
+ style: {
94
+ ...imgProps?.style,
95
+ ...(scaledImageSize || {
96
+ maxHeight: scrollAreaSize?.height,
97
+ maxWidth: scrollAreaSize?.width,
98
+ }),
99
+ // Only animate when scale is set, and we are not pointer zooming and the user does not prefer reduced motion
100
+ transition: scale && !isPointerZooming && !getPrefersReducedMotion() ? 'all 250ms' : undefined,
101
+ },
102
+ }}
103
+ loadingPlaceholderImageRef={loadingPlaceholderImageRef}
104
+ />
105
+ </SlideshowItem>
106
+ );
107
+ }, isEqual);