@lumx/react 3.7.5 → 3.7.6-alpha.0

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.
Files changed (29) hide show
  1. package/index.d.ts +76 -12
  2. package/index.js +1466 -718
  3. package/index.js.map +1 -1
  4. package/package.json +3 -3
  5. package/src/components/image-block/ImageBlock.tsx +13 -42
  6. package/src/components/image-block/ImageCaption.tsx +73 -0
  7. package/src/components/image-block/constants.ts +11 -0
  8. package/src/components/image-lightbox/ImageLightbox.stories.tsx +163 -0
  9. package/src/components/image-lightbox/ImageLightbox.test.tsx +252 -0
  10. package/src/components/image-lightbox/ImageLightbox.tsx +72 -0
  11. package/src/components/image-lightbox/constants.ts +11 -0
  12. package/src/components/image-lightbox/index.ts +2 -0
  13. package/src/components/image-lightbox/internal/ImageSlide.tsx +99 -0
  14. package/src/components/image-lightbox/internal/ImageSlideshow.tsx +158 -0
  15. package/src/components/image-lightbox/internal/useAnimateScroll.ts +55 -0
  16. package/src/components/image-lightbox/internal/usePointerZoom.ts +148 -0
  17. package/src/components/image-lightbox/types.ts +49 -0
  18. package/src/components/image-lightbox/useImageLightbox.tsx +122 -0
  19. package/src/components/lightbox/Lightbox.tsx +13 -12
  20. package/src/components/thumbnail/useFocusPointStyle.tsx +3 -4
  21. package/src/hooks/useElementSizeDependentOfWindowSize.ts +32 -0
  22. package/src/hooks/useImageSize.ts +17 -0
  23. package/src/index.ts +1 -0
  24. package/src/utils/findImage.tsx +3 -0
  25. package/src/utils/getPrefersReducedMotion.ts +6 -0
  26. package/src/utils/startViewTransition.ts +54 -0
  27. package/src/utils/type.ts +15 -0
  28. package/src/utils/unref.ts +6 -0
  29. package/src/hooks/useOnResize.ts +0 -41
package/package.json CHANGED
@@ -7,8 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@juggle/resize-observer": "^3.2.0",
10
- "@lumx/core": "^3.7.5",
11
- "@lumx/icons": "^3.7.5",
10
+ "@lumx/core": "^3.7.6-alpha.0",
11
+ "@lumx/icons": "^3.7.6-alpha.0",
12
12
  "@popperjs/core": "^2.5.4",
13
13
  "body-scroll-lock": "^3.1.5",
14
14
  "classnames": "^2.3.2",
@@ -112,5 +112,5 @@
112
112
  "build:storybook": "storybook build"
113
113
  },
114
114
  "sideEffects": false,
115
- "version": "3.7.5"
115
+ "version": "3.7.6-alpha.0"
116
116
  }
@@ -1,14 +1,14 @@
1
- import React, { CSSProperties, forwardRef, ReactNode } from 'react';
1
+ import React, { forwardRef, ReactNode } from 'react';
2
2
 
3
3
  import classNames from 'classnames';
4
4
 
5
- import isObject from 'lodash/isObject';
6
-
7
5
  import { Alignment, HorizontalAlignment, Size, Theme, Thumbnail } from '@lumx/react';
8
-
9
6
  import { Comp, GenericProps, HasTheme, ValueOf } from '@lumx/react/utils/type';
10
- import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
7
+ import { handleBasicClasses } from '@lumx/react/utils/className';
8
+
11
9
  import { ThumbnailProps } from '../thumbnail/Thumbnail';
10
+ import { ImageCaption, ImageCaptionMetadata } from './ImageCaption';
11
+ import { CLASSNAME, COMPONENT_NAME } from './constants';
12
12
 
13
13
  /**
14
14
  * Image block variants.
@@ -27,7 +27,7 @@ export type ImageBlockSize = Extract<Size, 'xl' | 'xxl'>;
27
27
  /**
28
28
  * Defines the props of the component.
29
29
  */
30
- export interface ImageBlockProps extends GenericProps, HasTheme {
30
+ export interface ImageBlockProps extends GenericProps, HasTheme, ImageCaptionMetadata {
31
31
  /** Action toolbar content. */
32
32
  actions?: ReactNode;
33
33
  /** Alignment. */
@@ -36,34 +36,16 @@ export interface ImageBlockProps extends GenericProps, HasTheme {
36
36
  alt: string;
37
37
  /** Caption position. */
38
38
  captionPosition?: ImageBlockCaptionPosition;
39
- /** Caption custom CSS style. */
40
- captionStyle?: CSSProperties;
41
- /** Image description. Can be either a string, or sanitized html. */
42
- description?: string | { __html: string };
43
39
  /** Whether the image has to fill its container height or not. */
44
40
  fillHeight?: boolean;
45
41
  /** Image URL. */
46
42
  image: string;
47
43
  /** Size variant. */
48
44
  size?: ImageBlockSize;
49
- /** Tag content. */
50
- tags?: ReactNode;
51
45
  /** Props to pass to the thumbnail (minus those already set by the ImageBlock props). */
52
46
  thumbnailProps?: Omit<ThumbnailProps, 'image' | 'size' | 'theme' | 'align' | 'fillHeight'>;
53
- /** Image title to display in the caption. */
54
- title?: string;
55
47
  }
56
48
 
57
- /**
58
- * Component display name.
59
- */
60
- const COMPONENT_NAME = 'ImageBlock';
61
-
62
- /**
63
- * Component default class name and class prefix.
64
- */
65
- const CLASSNAME = getRootClassName(COMPONENT_NAME);
66
-
67
49
  /**
68
50
  * Component default props.
69
51
  */
@@ -124,24 +106,13 @@ export const ImageBlock: Comp<ImageBlockProps, HTMLDivElement> = forwardRef((pro
124
106
  theme={theme}
125
107
  alt={(alt || title) as string}
126
108
  />
127
- {(title || description || tags) && (
128
- <figcaption className={`${CLASSNAME}__wrapper`} style={captionStyle}>
129
- {(title || description) && (
130
- <div className={`${CLASSNAME}__caption`}>
131
- {title && <span className={`${CLASSNAME}__title`}>{title}</span>}
132
- {/* Add an `&nbsp;` when there is description and title. */}
133
- {title && description && '\u00A0'}
134
- {isObject(description) && description.__html ? (
135
- // eslint-disable-next-line react/no-danger
136
- <span dangerouslySetInnerHTML={description} className={`${CLASSNAME}__description`} />
137
- ) : (
138
- <span className={`${CLASSNAME}__description`}>{description}</span>
139
- )}
140
- </div>
141
- )}
142
- {tags && <div className={`${CLASSNAME}__tags`}>{tags}</div>}
143
- </figcaption>
144
- )}
109
+ <ImageCaption
110
+ title={title}
111
+ description={description}
112
+ tags={tags}
113
+ captionStyle={captionStyle}
114
+ align={align}
115
+ />
145
116
  {actions && <div className={`${CLASSNAME}__actions`}>{actions}</div>}
146
117
  </figure>
147
118
  );
@@ -0,0 +1,73 @@
1
+ import React, { CSSProperties, ReactNode } from 'react';
2
+
3
+ import { FlexBox, HorizontalAlignment, Text } from '@lumx/react';
4
+ import { HasPolymorphicAs, HasTheme } from '@lumx/react/utils/type';
5
+ import { CLASSNAME } from './constants';
6
+
7
+ type As = 'div' | 'figcaption';
8
+
9
+ export type ImageCaptionMetadata = {
10
+ /** Image title to display in the caption. */
11
+ title?: string;
12
+ /** Image description. Can be either a string, or sanitized html. */
13
+ description?: string | { __html: string };
14
+ /** Tag content. */
15
+ tags?: ReactNode;
16
+ /** Caption custom CSS style. */
17
+ captionStyle?: CSSProperties;
18
+ };
19
+
20
+ export type ImageCaptionProps<AS extends As = 'figcaption'> = HasTheme &
21
+ HasPolymorphicAs<AS> &
22
+ ImageCaptionMetadata & {
23
+ /** Alignment. */
24
+ align?: HorizontalAlignment;
25
+ };
26
+
27
+ /** Internal component used to render image captions */
28
+ export const ImageCaption = <AS extends As>(props: ImageCaptionProps<AS>) => {
29
+ const { theme, as = 'figcaption', title, description, tags, captionStyle, align } = props;
30
+ if (!title && !description && !tags) return null;
31
+
32
+ const titleColor = theme === 'dark' ? ({ color: 'light' } as const) : undefined;
33
+ const descriptionColor = theme === 'dark' ? ({ color: 'light', colorVariant: 'L2' } as const) : undefined;
34
+
35
+ // Display description as string or HTML
36
+ const descriptionContent =
37
+ typeof description === 'string' ? { children: description } : { dangerouslySetInnerHTML: description };
38
+
39
+ return (
40
+ <FlexBox
41
+ as={as}
42
+ className={`${CLASSNAME}__wrapper`}
43
+ style={captionStyle}
44
+ orientation="horizontal"
45
+ vAlign={align}
46
+ >
47
+ {(title || description) && (
48
+ <div className={`${CLASSNAME}__caption`}>
49
+ {title && (
50
+ <Text as="span" className={`${CLASSNAME}__title`} {...titleColor}>
51
+ {title}
52
+ </Text>
53
+ )}
54
+ {/* Add an `&nbsp;` when there is description and title. */}
55
+ {title && description && '\u00A0'}
56
+ {description && (
57
+ <Text
58
+ as="span"
59
+ className={`${CLASSNAME}__description`}
60
+ {...descriptionColor}
61
+ {...descriptionContent}
62
+ />
63
+ )}
64
+ </div>
65
+ )}
66
+ {tags && (
67
+ <FlexBox orientation="horizontal" vAlign={align} className={`${CLASSNAME}__tags`}>
68
+ {tags}
69
+ </FlexBox>
70
+ )}
71
+ </FlexBox>
72
+ );
73
+ };
@@ -0,0 +1,11 @@
1
+ import { getRootClassName } from '@lumx/react/utils/className';
2
+
3
+ /**
4
+ * Component display name.
5
+ */
6
+ export const COMPONENT_NAME = 'ImageBlock';
7
+
8
+ /**
9
+ * Component default class name and class prefix.
10
+ */
11
+ export const CLASSNAME = getRootClassName(COMPONENT_NAME);
@@ -0,0 +1,163 @@
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
+ { image: IMAGES.portrait1s200, alt: 'Image 1' },
124
+ { image: IMAGES.landscape1s200, alt: 'Image 2' },
125
+ ]);
126
+ return (
127
+ <>
128
+ <Story args={{ ...args, ...imageLightboxProps }} />
129
+ <Button {...(getTriggerProps(0) as any)}>Image 1</Button>
130
+ <Button {...(getTriggerProps(1) as any)}>Image 2</Button>
131
+ </>
132
+ );
133
+ },
134
+ ],
135
+ // Disables Chromatic snapshot (not relevant for this story).
136
+ parameters: { chromatic: { disable: true } },
137
+ };
138
+
139
+ /**
140
+ * Open ImageLightbox with zoom and slideshow via clickable thumbnails in a Mosaic
141
+ */
142
+ export const WithMosaicTrigger = {
143
+ args: { ...SLIDESHOW_PROPS, ...ZOOM_PROPS },
144
+ decorators: [
145
+ (Story: any, { args }: any) => {
146
+ const { getTriggerProps, imageLightboxProps } = ImageLightbox.useImageLightbox(MULTIPLE_IMAGES);
147
+ return (
148
+ <>
149
+ <Story args={{ ...args, ...imageLightboxProps }} />
150
+ <Mosaic
151
+ thumbnails={MULTIPLE_IMAGES.map((image, index) => ({
152
+ ...image,
153
+ ...getTriggerProps(index),
154
+ }))}
155
+ />
156
+ </>
157
+ );
158
+ },
159
+ withWrapper({ style: { width: 300 } }),
160
+ ],
161
+ // Disables Chromatic snapshot (not relevant for this story).
162
+ parameters: { chromatic: { disable: true } },
163
+ };
@@ -0,0 +1,252 @@
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) => queryByClassName(imageLightbox, 'lumx-image-lightbox__image-slide'),
45
+ queryZoomInButton: (imageLightbox: HTMLElement) => within(imageLightbox).queryByRole('button', { name: 'Zoom in' }),
46
+ queryZoomOutButton: (imageLightbox: HTMLElement) =>
47
+ within(imageLightbox).queryByRole('button', { name: 'Zoom out' }),
48
+ queryPrevSlideButton: (imageLightbox: HTMLElement) =>
49
+ within(imageLightbox).queryByRole('button', { name: 'Previous' }),
50
+ queryNextSlideButton: (imageLightbox: HTMLElement) => within(imageLightbox).queryByRole('button', { name: 'Next' }),
51
+ querySlideButton: (imageLightbox: HTMLElement, slide: number) =>
52
+ within(imageLightbox).queryByRole('tab', { name: `Go to slide ${slide}` }),
53
+ };
54
+
55
+ describe(`<${ImageLightbox.displayName}>`, () => {
56
+ beforeEach(() => {
57
+ (useImageSize as any).mockReturnValue(null);
58
+ (useElementSizeDependentOfWindowSize as any).mockReturnValue([null, jest.fn()]);
59
+ });
60
+
61
+ describe('render', () => {
62
+ it('should render single image', () => {
63
+ setup(SingleImage.args);
64
+ const imageLightbox = queries.getImageLightbox();
65
+
66
+ // Should render
67
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
68
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
69
+
70
+ // Should not render
71
+ expect(queries.queryZoomInButton(imageLightbox)).not.toBeInTheDocument();
72
+ expect(queries.queryZoomOutButton(imageLightbox)).not.toBeInTheDocument();
73
+ expect(queries.queryPrevSlideButton(imageLightbox)).not.toBeInTheDocument();
74
+ expect(queries.queryNextSlideButton(imageLightbox)).not.toBeInTheDocument();
75
+ });
76
+
77
+ it('should render single image with zoom', () => {
78
+ setup(SingleImageWithZoom.args);
79
+ const imageLightbox = queries.getImageLightbox();
80
+
81
+ // Should render
82
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
83
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
84
+ expect(queries.queryZoomInButton(imageLightbox)).toBeInTheDocument();
85
+ expect(queries.queryZoomOutButton(imageLightbox)).toBeInTheDocument();
86
+
87
+ // Should not render
88
+ expect(queries.queryPrevSlideButton(imageLightbox)).not.toBeInTheDocument();
89
+ expect(queries.queryNextSlideButton(imageLightbox)).not.toBeInTheDocument();
90
+ });
91
+
92
+ it('should render multiple images', () => {
93
+ setup(MultipleImages.args);
94
+ const imageLightbox = queries.getImageLightbox();
95
+
96
+ // Should render
97
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
98
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
99
+ expect(queries.queryPrevSlideButton(imageLightbox)).toBeInTheDocument();
100
+ expect(queries.queryNextSlideButton(imageLightbox)).toBeInTheDocument();
101
+
102
+ // Should not render
103
+ expect(queries.queryZoomInButton(imageLightbox)).not.toBeInTheDocument();
104
+ expect(queries.queryZoomOutButton(imageLightbox)).not.toBeInTheDocument();
105
+ });
106
+
107
+ it('should render multiple images with set active image', () => {
108
+ setup({ ...MultipleImages.args, activeImageIndex: 1 });
109
+ const imageLightbox = queries.getImageLightbox();
110
+
111
+ // Should render
112
+ expect(queries.queryImage(imageLightbox, 'Image 2')).toBeInTheDocument();
113
+ });
114
+
115
+ it('should render multiple images with zoom', () => {
116
+ setup(MultipleImagesWithZoom.args);
117
+ const imageLightbox = queries.getImageLightbox();
118
+
119
+ // Should render
120
+ expect(queries.queryCloseButton(imageLightbox)).toBeInTheDocument();
121
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
122
+ expect(queries.queryPrevSlideButton(imageLightbox)).toBeInTheDocument();
123
+ expect(queries.queryNextSlideButton(imageLightbox)).toBeInTheDocument();
124
+ expect(queries.queryZoomInButton(imageLightbox)).toBeInTheDocument();
125
+ expect(queries.queryZoomOutButton(imageLightbox)).toBeInTheDocument();
126
+ });
127
+ });
128
+
129
+ describe('trigger', () => {
130
+ it('should move focus on open and close with single image lightbox', async () => {
131
+ const decorator = WithButtonTrigger.decorators[0];
132
+ const Story = ({ args }: any) => <ImageLightbox {...args} />;
133
+ const Render = () => decorator(Story, { args: baseProps });
134
+ render(<Render />);
135
+
136
+ // Focus the second button
137
+ await userEvent.tab();
138
+ await userEvent.tab();
139
+ const buttonTrigger = screen.getByRole('button', { name: 'Image 2' });
140
+ expect(buttonTrigger).toHaveFocus();
141
+
142
+ // Open image lightbox with the button trigger
143
+ await userEvent.keyboard('{enter}');
144
+
145
+ // Focus moved to the close button
146
+ const imageLightbox = queries.getImageLightbox();
147
+ expect(queries.queryCloseButton(imageLightbox)).toHaveFocus();
148
+
149
+ // Image lightbox opened on the correct image
150
+ expect(queries.queryImage(imageLightbox, 'Image 2')).toBeInTheDocument();
151
+
152
+ // Close on escape
153
+ await userEvent.keyboard('{escape}');
154
+ expect(imageLightbox).not.toBeInTheDocument();
155
+
156
+ // Focus moved back to the trigger button
157
+ expect(buttonTrigger).toHaveFocus();
158
+ });
159
+
160
+ it('should move focus on open and close with multiple image lightbox', async () => {
161
+ const decorator = WithMosaicTrigger.decorators[0];
162
+ const Story = ({ args }: any) => <ImageLightbox {...args} />;
163
+ const Render = () => decorator(Story, { args: { ...baseProps, ...WithMosaicTrigger.args } });
164
+ render(<Render />);
165
+
166
+ // Focus the first button & activate to open
167
+ await userEvent.tab();
168
+ const buttonTrigger = document.activeElement;
169
+ await userEvent.keyboard('{enter}');
170
+
171
+ // Focus moved to the first slide button
172
+ const imageLightbox = queries.getImageLightbox();
173
+ expect(queries.querySlideButton(imageLightbox, 1)).toHaveFocus();
174
+
175
+ // Image lightbox opened on the correct image
176
+ expect(queries.queryImage(imageLightbox, 'Image 1')).toBeInTheDocument();
177
+
178
+ // Close on escape
179
+ await userEvent.keyboard('{escape}');
180
+ expect(imageLightbox).not.toBeInTheDocument();
181
+
182
+ // Focus moved back to the trigger button
183
+ expect(buttonTrigger).toHaveFocus();
184
+ });
185
+ });
186
+
187
+ describe('zoom', () => {
188
+ const scrollAreaSize = { width: 600, height: 600 };
189
+ beforeEach(() => {
190
+ (useImageSize as any).mockImplementation((_: any, getInitialSize: any) => getInitialSize?.() || null);
191
+ (useElementSizeDependentOfWindowSize as any).mockReturnValue([scrollAreaSize, jest.fn()]);
192
+ });
193
+
194
+ it('should use the image initial size', () => {
195
+ setup({
196
+ images: [
197
+ { image: 'https://example.com/image.png', alt: 'Image 1', imgProps: { width: 200, height: 200 } },
198
+ ],
199
+ });
200
+ const imageLightbox = queries.getImageLightbox();
201
+ const image = queries.queryImage(imageLightbox, 'Image 1');
202
+ expect(image).toHaveStyle({
203
+ height: `200px`,
204
+ width: `200px`,
205
+ });
206
+ });
207
+
208
+ it('should zoom on zoom button pressed', async () => {
209
+ const { rerender } = setup(SingleImageWithZoom.args);
210
+ const imageLightbox = queries.getImageLightbox();
211
+
212
+ // Initial image style
213
+ const image = queries.queryImage(imageLightbox, 'Image 1');
214
+
215
+ expect(image).toHaveStyle({
216
+ maxHeight: `${scrollAreaSize.height}px`,
217
+ maxWidth: `${scrollAreaSize.width}px`,
218
+ });
219
+
220
+ // Update image size (simulate image loaded)
221
+ const imageSize = { width: 500, height: 300 };
222
+ (useImageSize as any).mockReturnValue(imageSize);
223
+ rerender();
224
+
225
+ // Image style updated
226
+ expect(image).toHaveStyle({ width: `${imageSize.width}px`, height: `${imageSize.height}px` });
227
+
228
+ // Scroll area is bigger than the image, it should not be focusable
229
+ expect(queries.queryScrollArea(imageLightbox)).not.toHaveAttribute('tabindex');
230
+
231
+ // Zoom in
232
+ const zoomInButton = queries.queryZoomInButton(imageLightbox) as any;
233
+ await userEvent.click(zoomInButton);
234
+ expect(image).toHaveStyle({ height: '450px', width: '750px' });
235
+
236
+ // Scroll area is smaller than the image, it should be focusable
237
+ expect(queries.queryScrollArea(imageLightbox)).toHaveAttribute('tabindex', '0');
238
+
239
+ // Zoom out
240
+ const zoomOutButton = queries.queryZoomOutButton(imageLightbox) as any;
241
+ await userEvent.click(zoomOutButton);
242
+ expect(image).toHaveStyle({ width: `${imageSize.width}px`, height: `${imageSize.height}px` });
243
+ });
244
+ });
245
+
246
+ // Common tests suite.
247
+ commonTestsSuiteRTL(setup, {
248
+ baseClassName: CLASSNAME,
249
+ forwardClassName: 'imageLightbox',
250
+ forwardAttributes: 'imageLightbox',
251
+ });
252
+ });
@@ -0,0 +1,72 @@
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 { mergeRefs } 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
+ return (
36
+ <Lightbox
37
+ ref={ref}
38
+ className={classNames(className, CLASSNAME)}
39
+ parentElement={parentElement}
40
+ isOpen={isOpen}
41
+ onClose={onClose}
42
+ closeButtonProps={closeButtonProps}
43
+ focusElement={currentPaginationItemRef}
44
+ {...forwardedProps}
45
+ >
46
+ <ClickAwayProvider childrenRefs={clickAwayChildrenRefs} callback={onClose}>
47
+ <ImageSlideshow
48
+ activeImageIndex={activeImageIndex}
49
+ slideGroupLabel={slideGroupLabel}
50
+ slideshowControlsProps={slideshowControlsProps}
51
+ images={images}
52
+ zoomInButtonProps={zoomInButtonProps}
53
+ zoomOutButtonProps={zoomOutButtonProps}
54
+ footerRef={footerRef}
55
+ activeImageRef={mergeRefs(propImageRef, imageRef)}
56
+ currentPaginationItemRef={currentPaginationItemRef}
57
+ />
58
+ </ClickAwayProvider>
59
+ </Lightbox>
60
+ );
61
+ });
62
+ Inner.displayName = COMPONENT_NAME;
63
+ Inner.className = CLASSNAME;
64
+
65
+ /**
66
+ * ImageLightbox component.
67
+ *
68
+ * @param props Component props.
69
+ * @param ref Component ref.
70
+ * @return React element.
71
+ */
72
+ export const ImageLightbox = Object.assign(Inner, { useImageLightbox });