@lumx/react 3.8.1-alpha.0 → 3.8.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.
Files changed (31) hide show
  1. package/index.d.ts +6 -63
  2. package/index.js +559 -1292
  3. package/index.js.map +1 -1
  4. package/package.json +3 -3
  5. package/src/components/image-block/ImageBlock.test.tsx +28 -0
  6. package/src/components/image-block/ImageBlock.tsx +5 -1
  7. package/src/components/image-block/ImageCaption.tsx +54 -8
  8. package/src/components/thumbnail/useFocusPointStyle.tsx +4 -3
  9. package/src/index.ts +0 -1
  10. package/src/utils/type.ts +0 -15
  11. package/src/components/image-lightbox/ImageLightbox.stories.tsx +0 -165
  12. package/src/components/image-lightbox/ImageLightbox.test.tsx +0 -253
  13. package/src/components/image-lightbox/ImageLightbox.tsx +0 -72
  14. package/src/components/image-lightbox/constants.ts +0 -11
  15. package/src/components/image-lightbox/index.ts +0 -2
  16. package/src/components/image-lightbox/internal/ImageSlide.tsx +0 -107
  17. package/src/components/image-lightbox/internal/ImageSlideshow.tsx +0 -173
  18. package/src/components/image-lightbox/internal/useAnimateScroll.ts +0 -55
  19. package/src/components/image-lightbox/internal/usePointerZoom.ts +0 -148
  20. package/src/components/image-lightbox/types.ts +0 -50
  21. package/src/components/image-lightbox/useImageLightbox.tsx +0 -130
  22. package/src/hooks/useElementSizeDependentOfWindowSize.ts +0 -32
  23. package/src/hooks/useImageSize.ts +0 -17
  24. package/src/stories/generated/ImageLightbox/Demos.stories.tsx +0 -6
  25. package/src/utils/DOM/findImage.tsx +0 -3
  26. package/src/utils/DOM/startViewTransition.ts +0 -56
  27. package/src/utils/browser/getPrefersReducedMotion.ts +0 -6
  28. package/src/utils/object/isEqual.test.ts +0 -25
  29. package/src/utils/object/isEqual.ts +0 -11
  30. package/src/utils/react/unref.ts +0 -7
  31. package/src/utils/unref.ts +0 -0
@@ -1,253 +0,0 @@
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
- });
@@ -1,72 +0,0 @@
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 });
@@ -1,11 +0,0 @@
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);
@@ -1,2 +0,0 @@
1
- export { ImageLightbox } from './ImageLightbox';
2
- export type { ImageLightboxProps } from './types';
@@ -1,107 +0,0 @@
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);
@@ -1,173 +0,0 @@
1
- import React from 'react';
2
-
3
- import { mdiMagnifyMinusOutline, mdiMagnifyPlusOutline } from '@lumx/icons';
4
- import { FlexBox, IconButton, Slides, SlideshowControls } from '@lumx/react';
5
- import { mergeRefs } from '@lumx/react/utils/mergeRefs';
6
-
7
- import memoize from 'lodash/memoize';
8
- import { ImageCaption } from '../../image-block/ImageCaption';
9
- import { CLASSNAME } from '../constants';
10
- import type { ImagesProps, InheritedSlideShowProps, ZoomButtonProps } from '../types';
11
- import { ImageSlide } from './ImageSlide';
12
-
13
- export interface ImageSlideshowProps extends InheritedSlideShowProps, ZoomButtonProps, ImagesProps {
14
- currentPaginationItemRef?: React.Ref<HTMLButtonElement>;
15
- footerRef?: React.Ref<HTMLDivElement>;
16
- }
17
-
18
- /** Internal image slideshow component for ImageLightbox */
19
- export const ImageSlideshow: React.FC<ImageSlideshowProps> = ({
20
- activeImageIndex,
21
- images,
22
- slideGroupLabel,
23
- zoomInButtonProps,
24
- zoomOutButtonProps,
25
- slideshowControlsProps,
26
- currentPaginationItemRef,
27
- footerRef,
28
- activeImageRef,
29
- }) => {
30
- const {
31
- activeIndex,
32
- slideshowId,
33
- setSlideshow,
34
- slideshowSlidesId,
35
- slidesCount,
36
- onNextClick,
37
- onPaginationClick,
38
- onPreviousClick,
39
- toggleAutoPlay,
40
- } = SlideshowControls.useSlideshowControls({
41
- itemsCount: images.length,
42
- activeIndex: activeImageIndex,
43
- });
44
-
45
- // Image metadata (caption)
46
- const title = images[activeIndex]?.title;
47
- const description = images[activeIndex]?.description;
48
- const tags = images[activeIndex]?.tags;
49
- const metadata =
50
- title || description || tags ? (
51
- <ImageCaption theme="dark" as="div" title={title} description={description} tags={tags} align="center" />
52
- ) : null;
53
-
54
- // Slideshow controls
55
- const slideShowControls =
56
- slidesCount > 1 && slideshowControlsProps ? (
57
- <SlideshowControls
58
- theme="dark"
59
- activeIndex={activeIndex}
60
- slidesCount={slidesCount}
61
- onNextClick={onNextClick}
62
- onPreviousClick={onPreviousClick}
63
- onPaginationClick={onPaginationClick}
64
- {...slideshowControlsProps}
65
- paginationItemProps={(index: number) => {
66
- const props = slideshowControlsProps?.paginationItemProps?.(index) || {};
67
- return {
68
- ...props,
69
- ref: mergeRefs(
70
- (props as any)?.ref,
71
- // Focus the active pagination item once on mount
72
- activeIndex === index ? currentPaginationItemRef : undefined,
73
- ),
74
- };
75
- }}
76
- />
77
- ) : null;
78
-
79
- // Zoom controls
80
- const [scale, setScale] = React.useState<number | undefined>(undefined);
81
- const zoomEnabled = zoomInButtonProps && zoomOutButtonProps;
82
- const onScaleChange = React.useMemo(() => {
83
- if (!zoomEnabled) return undefined;
84
- return (newScale: number) => {
85
- setScale((prevScale = 1) => Math.max(1, newScale * prevScale));
86
- };
87
- }, [zoomEnabled]);
88
- const zoomIn = React.useCallback(() => onScaleChange?.(1.5), [onScaleChange]);
89
- const zoomOut = React.useCallback(() => onScaleChange?.(0.5), [onScaleChange]);
90
- React.useEffect(() => {
91
- // Reset scale on slide change
92
- if (activeIndex) setScale(undefined);
93
- }, [activeIndex]);
94
- const zoomControls = zoomEnabled && (
95
- <>
96
- <IconButton
97
- {...zoomInButtonProps}
98
- theme="dark"
99
- emphasis="low"
100
- icon={mdiMagnifyPlusOutline}
101
- onClick={zoomIn}
102
- />
103
- <IconButton
104
- {...zoomOutButtonProps}
105
- theme="dark"
106
- emphasis="low"
107
- isDisabled={!scale || scale <= 1}
108
- icon={mdiMagnifyMinusOutline}
109
- onClick={zoomOut}
110
- />
111
- </>
112
- );
113
-
114
- const getImgRef = React.useMemo(
115
- () =>
116
- memoize(
117
- (index: number, isActive: boolean) => {
118
- return mergeRefs(images?.[index].imgRef, isActive ? activeImageRef : undefined);
119
- },
120
- // memoize based on both arguments
121
- (...args) => args.join(),
122
- ),
123
- [images, activeImageRef],
124
- );
125
-
126
- return (
127
- <>
128
- <Slides
129
- activeIndex={activeIndex}
130
- theme="dark"
131
- slideGroupLabel={slideGroupLabel}
132
- fillHeight
133
- id={slideshowId}
134
- ref={setSlideshow}
135
- slidesId={slideshowSlidesId}
136
- toggleAutoPlay={toggleAutoPlay}
137
- >
138
- {images.map(({ image, imgRef, ...imageProps }, index) => {
139
- const isActive = index === activeIndex;
140
- return (
141
- <ImageSlide
142
- isActive={isActive}
143
- key={image}
144
- image={{
145
- ...imageProps,
146
- image,
147
- imgRef: getImgRef(index, isActive),
148
- }}
149
- scale={isActive ? scale : undefined}
150
- onScaleChange={onScaleChange}
151
- />
152
- );
153
- })}
154
- </Slides>
155
- {(metadata || slideShowControls || zoomControls) && (
156
- <FlexBox
157
- ref={footerRef}
158
- className={`${CLASSNAME}__footer`}
159
- orientation="vertical"
160
- vAlign="center"
161
- gap="big"
162
- >
163
- {metadata}
164
-
165
- <FlexBox className={`${CLASSNAME}__footer-actions`} orientation="horizontal" gap="regular">
166
- {slideShowControls}
167
- {zoomControls}
168
- </FlexBox>
169
- </FlexBox>
170
- )}
171
- </>
172
- );
173
- };
@@ -1,55 +0,0 @@
1
- import React from 'react';
2
- import type { Point, RectSize } from '@lumx/react/utils/type';
3
-
4
- /** Maintains the scroll position centered relative to the original scroll area's dimensions when the content scales. */
5
- export function useAnimateScroll(scrollAreaRef: React.RefObject<HTMLDivElement>) {
6
- return React.useMemo(() => {
7
- let animationFrame: number | null = null;
8
-
9
- return function animate(centerPoint?: Point, initialScrollAreaSize?: RectSize) {
10
- const scrollArea = scrollAreaRef.current as HTMLDivElement;
11
- if (!scrollArea) {
12
- return;
13
- }
14
-
15
- // Cancel previously running animation
16
- if (animationFrame) cancelAnimationFrame(animationFrame);
17
-
18
- // Center on the given point or else on the scroll area visual center
19
- const clientHeightRatio = centerPoint?.y ? centerPoint.y / scrollArea.clientHeight : 0.5;
20
- const clientWidthRatio = centerPoint?.x ? centerPoint.x / scrollArea.clientWidth : 0.5;
21
-
22
- const initialScrollHeight = initialScrollAreaSize?.height || scrollArea.scrollHeight;
23
- const initialScrollWidth = initialScrollAreaSize?.width || scrollArea.scrollWidth;
24
-
25
- const heightCenter = scrollArea.scrollTop + scrollArea.clientHeight * clientHeightRatio;
26
- const heightRatio = heightCenter / initialScrollHeight;
27
-
28
- const widthCenter = scrollArea.scrollLeft + scrollArea.clientWidth * clientWidthRatio;
29
- const widthRatio = widthCenter / initialScrollWidth;
30
-
31
- let prevScrollHeight = 0;
32
- let prevScrollWidth = 0;
33
-
34
- function nextFrame() {
35
- const { scrollHeight, scrollWidth, clientHeight, clientWidth } = scrollArea;
36
-
37
- // Scroll area stopped expanding => stop animation
38
- if (scrollHeight === prevScrollHeight && scrollWidth === prevScrollWidth) {
39
- animationFrame = null;
40
- return;
41
- }
42
-
43
- // Compute next scroll position
44
- const top = heightRatio * scrollHeight - clientHeight * clientHeightRatio;
45
- const left = widthRatio * scrollWidth - clientWidth * clientWidthRatio;
46
-
47
- scrollArea.scrollTo({ top, left });
48
- prevScrollHeight = scrollHeight;
49
- prevScrollWidth = scrollWidth;
50
- animationFrame = requestAnimationFrame(nextFrame);
51
- }
52
- animationFrame = requestAnimationFrame(nextFrame);
53
- };
54
- }, [scrollAreaRef]);
55
- }