@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.
Files changed (34) hide show
  1. package/index.d.ts +69 -5
  2. package/index.js +1322 -549
  3. package/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/components/checkbox/Checkbox.stories.tsx +9 -0
  6. package/src/components/checkbox/Checkbox.test.tsx +12 -0
  7. package/src/components/checkbox/Checkbox.tsx +25 -9
  8. package/src/components/image-lightbox/ImageLightbox.stories.tsx +165 -0
  9. package/src/components/image-lightbox/ImageLightbox.test.tsx +253 -0
  10. package/src/components/image-lightbox/ImageLightbox.tsx +72 -0
  11. package/src/components/image-lightbox/constants.ts +11 -0
  12. package/src/components/image-lightbox/index.ts +2 -0
  13. package/src/components/image-lightbox/internal/ImageSlide.tsx +107 -0
  14. package/src/components/image-lightbox/internal/ImageSlideshow.tsx +173 -0
  15. package/src/components/image-lightbox/internal/useAnimateScroll.ts +55 -0
  16. package/src/components/image-lightbox/internal/usePointerZoom.ts +148 -0
  17. package/src/components/image-lightbox/types.ts +50 -0
  18. package/src/components/image-lightbox/useImageLightbox.tsx +130 -0
  19. package/src/components/thumbnail/Thumbnail.stories.tsx +35 -0
  20. package/src/components/thumbnail/Thumbnail.tsx +21 -2
  21. package/src/components/thumbnail/useFocusPointStyle.tsx +3 -4
  22. package/src/hooks/useElementSizeDependentOfWindowSize.ts +32 -0
  23. package/src/hooks/useImageSize.ts +17 -0
  24. package/src/index.ts +1 -0
  25. package/src/stories/generated/ImageLightbox/Demos.stories.tsx +6 -0
  26. package/src/utils/DOM/findImage.tsx +3 -0
  27. package/src/utils/DOM/startViewTransition.ts +56 -0
  28. package/src/utils/browser/getPrefersReducedMotion.ts +6 -0
  29. package/src/utils/object/isEqual.test.ts +25 -0
  30. package/src/utils/object/isEqual.ts +11 -0
  31. package/src/utils/react/unref.ts +7 -0
  32. package/src/utils/type.ts +15 -0
  33. package/src/utils/unref.ts +0 -0
  34. 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 { mergeRefs } from '@lumx/react/utils/mergeRefs';
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={mergeRefs(setImgElement, propImgRef)}
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: Size | undefined = useMemo(() => {
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<Size | undefined>(undefined);
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,6 @@
1
+ /**
2
+ * File generated when storybook is started. Do not edit directly!
3
+ */
4
+ export default { title: 'LumX components/image-lightbox/ImageLightbox Demos' };
5
+
6
+ export { App as Default } from './default';
@@ -0,0 +1,3 @@
1
+ /** Find image in element including the element */
2
+ export const findImage = (element: HTMLElement | null): HTMLImageElement | null =>
3
+ element?.matches('img') ? (element as HTMLImageElement) : element?.querySelector('img') || null;
@@ -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,6 @@
1
+ import { WINDOW } from '@lumx/react/constants';
2
+
3
+ /** Check if user prefers reduced motion */
4
+ export function getPrefersReducedMotion() {
5
+ return WINDOW?.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
6
+ }
@@ -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
@@ -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
- }