@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.
- package/index.d.ts +76 -12
- package/index.js +1466 -718
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/image-block/ImageBlock.tsx +13 -42
- package/src/components/image-block/ImageCaption.tsx +73 -0
- package/src/components/image-block/constants.ts +11 -0
- package/src/components/image-lightbox/ImageLightbox.stories.tsx +163 -0
- package/src/components/image-lightbox/ImageLightbox.test.tsx +252 -0
- package/src/components/image-lightbox/ImageLightbox.tsx +72 -0
- package/src/components/image-lightbox/constants.ts +11 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/image-lightbox/internal/ImageSlide.tsx +99 -0
- package/src/components/image-lightbox/internal/ImageSlideshow.tsx +158 -0
- package/src/components/image-lightbox/internal/useAnimateScroll.ts +55 -0
- package/src/components/image-lightbox/internal/usePointerZoom.ts +148 -0
- package/src/components/image-lightbox/types.ts +49 -0
- package/src/components/image-lightbox/useImageLightbox.tsx +122 -0
- package/src/components/lightbox/Lightbox.tsx +13 -12
- package/src/components/thumbnail/useFocusPointStyle.tsx +3 -4
- package/src/hooks/useElementSizeDependentOfWindowSize.ts +32 -0
- package/src/hooks/useImageSize.ts +17 -0
- package/src/index.ts +1 -0
- package/src/utils/findImage.tsx +3 -0
- package/src/utils/getPrefersReducedMotion.ts +6 -0
- package/src/utils/startViewTransition.ts +54 -0
- package/src/utils/type.ts +15 -0
- package/src/utils/unref.ts +6 -0
- 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.
|
|
11
|
-
"@lumx/icons": "^3.7.
|
|
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.
|
|
115
|
+
"version": "3.7.6-alpha.0"
|
|
116
116
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import 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 {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 ` ` 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 });
|