@lumx/react 3.8.1 → 3.8.2-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 +63 -1
- package/index.js +1285 -542
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/image-lightbox/ImageLightbox.stories.tsx +165 -0
- package/src/components/image-lightbox/ImageLightbox.test.tsx +253 -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 +107 -0
- package/src/components/image-lightbox/internal/ImageSlideshow.tsx +173 -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 +50 -0
- package/src/components/image-lightbox/useImageLightbox.tsx +130 -0
- 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/stories/generated/ImageLightbox/Demos.stories.tsx +6 -0
- package/src/utils/DOM/findImage.tsx +3 -0
- package/src/utils/DOM/startViewTransition.ts +56 -0
- package/src/utils/browser/getPrefersReducedMotion.ts +6 -0
- package/src/utils/object/isEqual.test.ts +25 -0
- package/src/utils/object/isEqual.ts +11 -0
- package/src/utils/react/unref.ts +7 -0
- package/src/utils/type.ts +15 -0
- package/src/utils/unref.ts +0 -0
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.
|
|
10
|
-
"@lumx/icons": "^3.8.
|
|
9
|
+
"@lumx/core": "^3.8.2-alpha.0",
|
|
10
|
+
"@lumx/icons": "^3.8.2-alpha.0",
|
|
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.
|
|
114
|
+
"version": "3.8.2-alpha.0"
|
|
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,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 });
|
|
@@ -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,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);
|