@lumx/react 3.7.6-test.1 → 3.8.1-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 +69 -5
- package/index.js +1322 -549
- package/index.js.map +1 -1
- package/package.json +3 -4
- package/src/components/checkbox/Checkbox.stories.tsx +9 -0
- package/src/components/checkbox/Checkbox.test.tsx +12 -0
- package/src/components/checkbox/Checkbox.tsx +25 -9
- 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/Thumbnail.stories.tsx +35 -0
- package/src/components/thumbnail/Thumbnail.tsx +21 -2
- 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/src/hooks/useOnResize.ts +0 -41
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
+
import uniqueId from 'lodash/uniqueId';
|
|
4
|
+
|
|
3
5
|
import { mdiAbTesting } from '@lumx/icons';
|
|
4
6
|
import {
|
|
5
7
|
Alignment,
|
|
6
8
|
AspectRatio,
|
|
7
9
|
Badge,
|
|
10
|
+
Button,
|
|
8
11
|
FlexBox,
|
|
9
12
|
GridColumn,
|
|
10
13
|
Icon,
|
|
@@ -411,3 +414,35 @@ export const ObjectFit = {
|
|
|
411
414
|
withWrapper({ maxColumns: 3, itemMinWidth: 350 }, GridColumn),
|
|
412
415
|
],
|
|
413
416
|
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Demonstrate loading a small image and then use it as the loading placeholder image when loading a bigger image
|
|
420
|
+
*/
|
|
421
|
+
export const LoadingPlaceholderImage = () => {
|
|
422
|
+
const [isShown, setShown] = React.useState(false);
|
|
423
|
+
const imgRef = React.useRef() as React.RefObject<HTMLImageElement>;
|
|
424
|
+
return (
|
|
425
|
+
<>
|
|
426
|
+
<Button onClick={() => setShown((shown) => !shown)}>
|
|
427
|
+
Display bigger image using the small image as a placeholder
|
|
428
|
+
</Button>
|
|
429
|
+
<FlexBox orientation="horizontal">
|
|
430
|
+
<Thumbnail alt="Small image" imgRef={imgRef} image="https://picsum.photos/id/15/128/85" />
|
|
431
|
+
{isShown && (
|
|
432
|
+
<div style={{ maxHeight: 400 }}>
|
|
433
|
+
<Thumbnail
|
|
434
|
+
image={`https://picsum.photos/id/15/2500/1667?cacheBust${uniqueId()}`}
|
|
435
|
+
alt="Large image"
|
|
436
|
+
// Loading placeholder image
|
|
437
|
+
loadingPlaceholderImageRef={imgRef}
|
|
438
|
+
// Reserve space
|
|
439
|
+
imgProps={{ width: 2500, height: 1667 }}
|
|
440
|
+
/>
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
</FlexBox>
|
|
444
|
+
</>
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
// Disables Chromatic snapshot (not relevant for this story).
|
|
448
|
+
LoadingPlaceholderImage.parameters = { chromatic: { disable: true } };
|
|
@@ -17,7 +17,7 @@ import { Comp, Falsy, GenericProps, HasTheme } from '@lumx/react/utils/type';
|
|
|
17
17
|
import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
|
|
18
18
|
|
|
19
19
|
import { mdiImageBroken } from '@lumx/icons';
|
|
20
|
-
import {
|
|
20
|
+
import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
|
|
21
21
|
import { useImageLoad } from '@lumx/react/components/thumbnail/useImageLoad';
|
|
22
22
|
import { useFocusPointStyle } from '@lumx/react/components/thumbnail/useFocusPointStyle';
|
|
23
23
|
import { FocusPoint, ThumbnailSize, ThumbnailVariant } from './types';
|
|
@@ -58,6 +58,8 @@ export interface ThumbnailProps extends GenericProps, HasTheme {
|
|
|
58
58
|
size?: ThumbnailSize;
|
|
59
59
|
/** Image loading mode. */
|
|
60
60
|
loading?: ImgHTMLProps['loading'];
|
|
61
|
+
/** Ref of an existing placeholder image to display while loading. */
|
|
62
|
+
loadingPlaceholderImageRef?: React.RefObject<HTMLImageElement>;
|
|
61
63
|
/** On click callback. */
|
|
62
64
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
|
63
65
|
/** On key press callback. */
|
|
@@ -115,6 +117,7 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
|
|
|
115
117
|
isLoading: isLoadingProp,
|
|
116
118
|
objectFit,
|
|
117
119
|
loading,
|
|
120
|
+
loadingPlaceholderImageRef,
|
|
118
121
|
size,
|
|
119
122
|
theme,
|
|
120
123
|
variant,
|
|
@@ -159,6 +162,16 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
|
|
|
159
162
|
wrapperProps['aria-label'] = forwardedProps['aria-label'] || alt;
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
// If we have a loading placeholder image that is really loaded (complete)
|
|
166
|
+
const loadingPlaceholderImage =
|
|
167
|
+
(isLoading && loadingPlaceholderImageRef?.current?.complete && loadingPlaceholderImageRef?.current) ||
|
|
168
|
+
undefined;
|
|
169
|
+
|
|
170
|
+
// Set loading placeholder image as background
|
|
171
|
+
const loadingStyle = loadingPlaceholderImage
|
|
172
|
+
? { backgroundImage: `url(${loadingPlaceholderImage.src})` }
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
162
175
|
return (
|
|
163
176
|
<Wrapper
|
|
164
177
|
{...wrapperProps}
|
|
@@ -186,13 +199,19 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
|
|
|
186
199
|
>
|
|
187
200
|
<span className={`${CLASSNAME}__background`}>
|
|
188
201
|
<img
|
|
202
|
+
// Use placeholder image size
|
|
203
|
+
width={loadingPlaceholderImage?.naturalWidth}
|
|
204
|
+
height={loadingPlaceholderImage?.naturalHeight}
|
|
189
205
|
{...imgProps}
|
|
190
206
|
style={{
|
|
207
|
+
// Reserve space while loading (when possible)
|
|
208
|
+
width: isLoading ? imgProps?.width || loadingPlaceholderImage?.naturalWidth : undefined,
|
|
191
209
|
...imgProps?.style,
|
|
192
210
|
...imageErrorStyle,
|
|
193
211
|
...focusPointStyle,
|
|
212
|
+
...loadingStyle,
|
|
194
213
|
}}
|
|
195
|
-
ref={
|
|
214
|
+
ref={useMergeRefs(setImgElement, propImgRef)}
|
|
196
215
|
className={classNames(
|
|
197
216
|
handleBasicClasses({
|
|
198
217
|
prefix: `${CLASSNAME}__image`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CSSProperties, useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import { AspectRatio } from '@lumx/react/components';
|
|
3
3
|
import { ThumbnailProps } from '@lumx/react/components/thumbnail/Thumbnail';
|
|
4
|
+
import { RectSize } from '@lumx/react/utils/type';
|
|
4
5
|
|
|
5
6
|
// Calculate shift to center the focus point in the container.
|
|
6
7
|
export function shiftPosition({
|
|
@@ -24,8 +25,6 @@ export function shiftPosition({
|
|
|
24
25
|
return Math.floor(Math.max(Math.min(shift, 1), 0) * 100);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
type Size = { width: number; height: number };
|
|
28
|
-
|
|
29
28
|
// Compute CSS properties to apply the focus point.
|
|
30
29
|
export const useFocusPointStyle = (
|
|
31
30
|
{ image, aspectRatio, focusPoint, imgProps: { width, height } = {} }: ThumbnailProps,
|
|
@@ -33,7 +32,7 @@ export const useFocusPointStyle = (
|
|
|
33
32
|
isLoaded: boolean,
|
|
34
33
|
): CSSProperties => {
|
|
35
34
|
// Get natural image size from imgProps or img element.
|
|
36
|
-
const imageSize:
|
|
35
|
+
const imageSize: RectSize | undefined = useMemo(() => {
|
|
37
36
|
// Focus point is not applicable => exit early
|
|
38
37
|
if (!image || aspectRatio === AspectRatio.original || (!focusPoint?.x && !focusPoint?.y)) return undefined;
|
|
39
38
|
if (typeof width === 'number' && typeof height === 'number') return { width, height };
|
|
@@ -42,7 +41,7 @@ export const useFocusPointStyle = (
|
|
|
42
41
|
}, [aspectRatio, element, focusPoint?.x, focusPoint?.y, height, image, isLoaded, width]);
|
|
43
42
|
|
|
44
43
|
// Get container size (dependant on imageSize).
|
|
45
|
-
const [containerSize, setContainerSize] = useState<
|
|
44
|
+
const [containerSize, setContainerSize] = useState<RectSize | undefined>(undefined);
|
|
46
45
|
useEffect(
|
|
47
46
|
function updateContainerSize() {
|
|
48
47
|
const cWidth = element?.offsetWidth;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import throttle from 'lodash/throttle';
|
|
4
|
+
import { RectSize } from '@lumx/react/utils/type';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Observe element size (only works if it's size depends on the window size).
|
|
8
|
+
*
|
|
9
|
+
* (Not using ResizeObserver for better browser backward compat)
|
|
10
|
+
*
|
|
11
|
+
* @param elementRef Element to observe
|
|
12
|
+
* @return the size and a manual update callback
|
|
13
|
+
*/
|
|
14
|
+
export function useElementSizeDependentOfWindowSize(
|
|
15
|
+
elementRef: React.RefObject<HTMLElement>,
|
|
16
|
+
): [RectSize | null, () => void] {
|
|
17
|
+
const [size, setSize] = React.useState<null | RectSize>(null);
|
|
18
|
+
const updateSize = React.useMemo(
|
|
19
|
+
() =>
|
|
20
|
+
throttle(() => {
|
|
21
|
+
const newSize = elementRef.current?.getBoundingClientRect();
|
|
22
|
+
if (newSize) setSize(newSize);
|
|
23
|
+
}, 10),
|
|
24
|
+
[elementRef],
|
|
25
|
+
);
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
updateSize();
|
|
28
|
+
window.addEventListener('resize', updateSize);
|
|
29
|
+
return () => window.removeEventListener('resize', updateSize);
|
|
30
|
+
}, [updateSize]);
|
|
31
|
+
return [size, updateSize];
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { RectSize } from '@lumx/react/utils/type';
|
|
3
|
+
|
|
4
|
+
/** Get natural image size after load. */
|
|
5
|
+
export function useImageSize(imgRef: React.RefObject<HTMLImageElement>, getInitialSize?: () => RectSize | null) {
|
|
6
|
+
const [imageSize, setImageSize] = React.useState<null | RectSize>(getInitialSize || null);
|
|
7
|
+
React.useEffect(() => {
|
|
8
|
+
const { current: img } = imgRef;
|
|
9
|
+
if (!img) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const onLoad = () => setImageSize({ width: img.naturalWidth, height: img.naturalHeight });
|
|
13
|
+
img.addEventListener('load', onLoad);
|
|
14
|
+
return () => img.removeEventListener('load', onLoad);
|
|
15
|
+
}, [imgRef]);
|
|
16
|
+
return imageSize;
|
|
17
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export * from './components/grid';
|
|
|
25
25
|
export * from './components/grid-column';
|
|
26
26
|
export * from './components/icon';
|
|
27
27
|
export * from './components/image-block';
|
|
28
|
+
export * from './components/image-lightbox';
|
|
28
29
|
export * from './components/inline-list';
|
|
29
30
|
export * from './components/input-helper';
|
|
30
31
|
export * from './components/input-label';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import ReactDOM from 'react-dom';
|
|
2
|
+
|
|
3
|
+
import { MaybeElementOrRef } from '@lumx/react/utils/type';
|
|
4
|
+
|
|
5
|
+
import { unref } from '../react/unref';
|
|
6
|
+
import { getPrefersReducedMotion } from '../browser/getPrefersReducedMotion';
|
|
7
|
+
|
|
8
|
+
function setTransitionViewName(elementRef: MaybeElementOrRef<HTMLElement>, name: string | null | undefined) {
|
|
9
|
+
const element = unref(elementRef) as any;
|
|
10
|
+
if (element) element.style.viewTransitionName = name;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wrapper around the `document.startViewTransition` handling browser incompatibilities, react DOM flush and
|
|
15
|
+
* user preference.
|
|
16
|
+
*
|
|
17
|
+
* @param changes callback containing the changes to apply within the view transition.
|
|
18
|
+
* @param setViewTransitionName set the `viewTransitionName` style on a `source` & `target` to morph these elements.
|
|
19
|
+
*/
|
|
20
|
+
export async function startViewTransition({
|
|
21
|
+
changes,
|
|
22
|
+
viewTransitionName,
|
|
23
|
+
}: {
|
|
24
|
+
changes: () => void;
|
|
25
|
+
viewTransitionName: {
|
|
26
|
+
source: MaybeElementOrRef<HTMLElement>;
|
|
27
|
+
target: MaybeElementOrRef<HTMLElement>;
|
|
28
|
+
name: string;
|
|
29
|
+
};
|
|
30
|
+
}) {
|
|
31
|
+
const start = (document as any)?.startViewTransition?.bind(document);
|
|
32
|
+
const prefersReducedMotion = getPrefersReducedMotion();
|
|
33
|
+
const { flushSync } = ReactDOM as any;
|
|
34
|
+
if (prefersReducedMotion || !start || !flushSync || !viewTransitionName?.source || !viewTransitionName?.target) {
|
|
35
|
+
// Skip, apply changes without a transition
|
|
36
|
+
changes();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Set transition name on source element
|
|
41
|
+
setTransitionViewName(viewTransitionName.source, viewTransitionName.name);
|
|
42
|
+
|
|
43
|
+
// Start view transition, apply changes & flush to DOM
|
|
44
|
+
await start(() => {
|
|
45
|
+
// Un-set transition name on source element
|
|
46
|
+
setTransitionViewName(viewTransitionName.source, null);
|
|
47
|
+
|
|
48
|
+
flushSync(changes);
|
|
49
|
+
|
|
50
|
+
// Set transition name on target element
|
|
51
|
+
setTransitionViewName(viewTransitionName.target, viewTransitionName.name);
|
|
52
|
+
}).updateCallbackDone;
|
|
53
|
+
|
|
54
|
+
// Un-set transition name on target element
|
|
55
|
+
setTransitionViewName(viewTransitionName.target, null);
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isEqual } from './isEqual';
|
|
2
|
+
|
|
3
|
+
test(isEqual.name, () => {
|
|
4
|
+
expect(isEqual('', '')).toBe(true);
|
|
5
|
+
expect(isEqual(0, 0)).toBe(true);
|
|
6
|
+
expect(isEqual(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY)).toBe(true);
|
|
7
|
+
|
|
8
|
+
expect(isEqual('', 0)).toBe(false);
|
|
9
|
+
|
|
10
|
+
expect(isEqual({}, {})).toBe(true);
|
|
11
|
+
expect(isEqual({ a: 1 }, { a: 1 })).toBe(true);
|
|
12
|
+
expect(isEqual({ a: { a: 1 } }, { a: { a: 1 } })).toBe(true);
|
|
13
|
+
|
|
14
|
+
expect(isEqual([], [])).toBe(true);
|
|
15
|
+
|
|
16
|
+
expect(isEqual([1], [2])).toBe(false);
|
|
17
|
+
expect(isEqual([1], [1, 2])).toBe(false);
|
|
18
|
+
expect(isEqual([1, 2], [2, 1])).toBe(false);
|
|
19
|
+
|
|
20
|
+
expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
|
|
21
|
+
expect(isEqual({ a: 1 }, {})).toBe(false);
|
|
22
|
+
expect(isEqual({}, { a: 1 })).toBe(false);
|
|
23
|
+
expect(isEqual({ a: { a: 1 } }, { a: { a: 2 } })).toBe(false);
|
|
24
|
+
expect(isEqual({ a: 1 }, { a: 1, b: 1 })).toBe(false);
|
|
25
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Minimal recursive deep equal of JS values */
|
|
2
|
+
export function isEqual(obj1: any, obj2: any): boolean {
|
|
3
|
+
if (obj1 === obj2) return true;
|
|
4
|
+
if (typeof obj1 === 'object' && typeof obj2 === 'object') {
|
|
5
|
+
const keys1 = Object.keys(obj1);
|
|
6
|
+
const keys2 = Object.keys(obj2);
|
|
7
|
+
if (keys1.length !== keys2.length) return false;
|
|
8
|
+
return keys1.every((key1) => isEqual(obj1[key1], obj2[key1]));
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { MaybeElementOrRef } from '@lumx/react/utils/type';
|
|
2
|
+
|
|
3
|
+
/** Unref a react ref or element */
|
|
4
|
+
export function unref(maybeElement: MaybeElementOrRef<HTMLElement>) {
|
|
5
|
+
if (maybeElement instanceof HTMLElement) return maybeElement;
|
|
6
|
+
return maybeElement?.current;
|
|
7
|
+
}
|
package/src/utils/type.ts
CHANGED
|
@@ -139,3 +139,18 @@ export type ComponentRef<C> = C extends keyof JSX.IntrinsicElements
|
|
|
139
139
|
: C extends React.JSXElementConstructor<{ ref?: infer R }>
|
|
140
140
|
? R
|
|
141
141
|
: never;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Rectangle size
|
|
145
|
+
*/
|
|
146
|
+
export type RectSize = { width: number; height: number };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Maybe a HTMLElement or a React ref of a HTMLElement
|
|
150
|
+
*/
|
|
151
|
+
export type MaybeElementOrRef<E extends HTMLElement> = E | React.RefObject<E | null> | null | undefined;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A point coordinate in 2D space
|
|
155
|
+
*/
|
|
156
|
+
export type Point = { x: number; y: number };
|
|
File without changes
|
package/src/hooks/useOnResize.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { Callback, Falsy } from '@lumx/react/utils/type';
|
|
2
|
-
import { MutableRefObject, RefObject, useEffect, useRef } from 'react';
|
|
3
|
-
import { WINDOW } from '@lumx/react/constants';
|
|
4
|
-
import { ResizeObserver as Polyfill } from '@juggle/resize-observer';
|
|
5
|
-
|
|
6
|
-
const ResizeObserver: typeof Polyfill = (WINDOW as any)?.ResizeObserver || Polyfill;
|
|
7
|
-
|
|
8
|
-
export function useOnResize(element: HTMLElement | Falsy, update: RefObject<Callback>): void {
|
|
9
|
-
const observerRef = useRef(null) as MutableRefObject<Polyfill | null>;
|
|
10
|
-
const previousSize = useRef<{ width: number; height: number }>();
|
|
11
|
-
|
|
12
|
-
useEffect(() => {
|
|
13
|
-
if (!element || !update) {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
previousSize.current = undefined;
|
|
18
|
-
const observer =
|
|
19
|
-
observerRef.current ||
|
|
20
|
-
new ResizeObserver(([entry]) => {
|
|
21
|
-
const updateFunction = update.current;
|
|
22
|
-
if (!updateFunction) {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const { width, height } = entry.contentRect;
|
|
27
|
-
if (previousSize.current?.width === width && previousSize.current?.height === height) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
window.requestAnimationFrame(() => updateFunction());
|
|
32
|
-
previousSize.current = entry.contentRect;
|
|
33
|
-
});
|
|
34
|
-
if (!observerRef.current) observerRef.current = observer;
|
|
35
|
-
|
|
36
|
-
observer.observe(element);
|
|
37
|
-
return () => {
|
|
38
|
-
observer.unobserve(element);
|
|
39
|
-
};
|
|
40
|
-
}, [element, update]);
|
|
41
|
-
}
|