@lumx/react 2.1.9 → 2.2.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.
- package/esm/_internal/Avatar2.js +7 -2
- package/esm/_internal/Avatar2.js.map +1 -1
- package/esm/_internal/Slider2.js +21 -2
- package/esm/_internal/Slider2.js.map +1 -1
- package/esm/_internal/Thumbnail2.js +181 -782
- package/esm/_internal/Thumbnail2.js.map +1 -1
- package/esm/_internal/Tooltip2.js +0 -5
- package/esm/_internal/Tooltip2.js.map +1 -1
- package/esm/_internal/UserBlock.js +41 -17
- package/esm/_internal/UserBlock.js.map +1 -1
- package/esm/_internal/avatar.js +0 -3
- package/esm/_internal/avatar.js.map +1 -1
- package/esm/_internal/comment-block.js +0 -3
- package/esm/_internal/comment-block.js.map +1 -1
- package/esm/_internal/image-block.js +0 -3
- package/esm/_internal/image-block.js.map +1 -1
- package/esm/_internal/link-preview.js +0 -3
- package/esm/_internal/link-preview.js.map +1 -1
- package/esm/_internal/mdi.js +2 -2
- package/esm/_internal/mdi.js.map +1 -1
- package/esm/_internal/mosaic.js +0 -3
- package/esm/_internal/mosaic.js.map +1 -1
- package/esm/_internal/post-block.js +0 -3
- package/esm/_internal/post-block.js.map +1 -1
- package/esm/_internal/slider.js +1 -2
- package/esm/_internal/slider.js.map +1 -1
- package/esm/_internal/thumbnail.js +1 -4
- package/esm/_internal/thumbnail.js.map +1 -1
- package/esm/_internal/types.js +1 -0
- package/esm/_internal/types.js.map +1 -1
- package/esm/_internal/user-block.js +2 -3
- package/esm/_internal/user-block.js.map +1 -1
- package/esm/index.js +2 -4
- package/esm/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/avatar/Avatar.stories.tsx +30 -53
- package/src/components/avatar/Avatar.tsx +9 -0
- package/src/components/avatar/__snapshots__/Avatar.test.tsx.snap +220 -357
- package/src/components/image-block/__snapshots__/ImageBlock.test.tsx.snap +1 -1
- package/src/components/mosaic/__snapshots__/Mosaic.test.tsx.snap +30 -30
- package/src/components/post-block/__snapshots__/PostBlock.test.tsx.snap +1 -1
- package/src/components/slideshow/__snapshots__/Slideshow.test.tsx.snap +10 -10
- package/src/components/table/__snapshots__/Table.test.tsx.snap +3 -3
- package/src/components/thumbnail/Thumbnail.stories.tsx +428 -52
- package/src/components/thumbnail/Thumbnail.test.tsx +8 -2
- package/src/components/thumbnail/Thumbnail.tsx +84 -47
- package/src/components/thumbnail/__snapshots__/Thumbnail.test.tsx.snap +28 -81
- package/src/components/thumbnail/index.ts +1 -0
- package/src/components/thumbnail/useFocusPointStyle.tsx +89 -0
- package/src/components/thumbnail/useImageLoad.ts +24 -23
- package/src/components/tooltip/Tooltip.stories.tsx +7 -4
- package/src/components/tooltip/useInjectTooltipRef.tsx +1 -3
- package/src/components/user-block/UserBlock.stories.tsx +65 -105
- package/src/components/user-block/UserBlock.test.tsx +6 -0
- package/src/components/user-block/UserBlock.tsx +50 -25
- package/src/components/user-block/__snapshots__/UserBlock.test.tsx.snap +113 -144
- package/src/stories/generated/Badge/Demos.stories.tsx +1 -0
- package/src/stories/generated/Flag/Demos.stories.tsx +6 -0
- package/src/stories/generated/List/Demos.stories.tsx +2 -0
- package/src/stories/generated/Thumbnail/Demos.stories.tsx +1 -0
- package/src/stories/knobs/focusKnob.ts +1 -1
- package/src/stories/knobs/image.ts +35 -3
- package/src/stories/utils/CustomLink.tsx +7 -0
- package/types.d.ts +21 -4
- package/esm/_internal/clamp.js +0 -22
- package/esm/_internal/clamp.js.map +0 -1
- package/src/components/thumbnail/useClickable.ts +0 -26
- package/src/components/thumbnail/useFocusPoint.ts +0 -154
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, {
|
|
2
|
+
CSSProperties,
|
|
2
3
|
forwardRef,
|
|
3
4
|
ImgHTMLAttributes,
|
|
4
5
|
KeyboardEventHandler,
|
|
@@ -6,7 +7,6 @@ import React, {
|
|
|
6
7
|
ReactElement,
|
|
7
8
|
ReactNode,
|
|
8
9
|
Ref,
|
|
9
|
-
useRef,
|
|
10
10
|
useState,
|
|
11
11
|
} from 'react';
|
|
12
12
|
import classNames from 'classnames';
|
|
@@ -15,12 +15,10 @@ import { AspectRatio, HorizontalAlignment, Icon, Size, Theme } from '@lumx/react
|
|
|
15
15
|
|
|
16
16
|
import { Comp, GenericProps, getRootClassName, handleBasicClasses } from '@lumx/react/utils';
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import { isInternetExplorer } from '@lumx/react/utils/isInternetExplorer';
|
|
18
|
+
import { mdiImageBroken } from '@lumx/icons';
|
|
20
19
|
import { mergeRefs } from '@lumx/react/utils/mergeRefs';
|
|
21
|
-
import { useFocusPoint } from '@lumx/react/components/thumbnail/useFocusPoint';
|
|
22
20
|
import { useImageLoad } from '@lumx/react/components/thumbnail/useImageLoad';
|
|
23
|
-
import {
|
|
21
|
+
import { useFocusPointStyle } from '@lumx/react/components/thumbnail/useFocusPointStyle';
|
|
24
22
|
import { FocusPoint, ThumbnailSize, ThumbnailVariant } from './types';
|
|
25
23
|
|
|
26
24
|
type ImgHTMLProps = ImgHTMLAttributes<HTMLImageElement>;
|
|
@@ -51,6 +49,8 @@ export interface ThumbnailProps extends GenericProps {
|
|
|
51
49
|
imgProps?: ImgHTMLProps;
|
|
52
50
|
/** Reference to the native <img> element. */
|
|
53
51
|
imgRef?: Ref<HTMLImageElement>;
|
|
52
|
+
/** Set to true to force the display of the loading skeleton. */
|
|
53
|
+
isLoading?: boolean;
|
|
54
54
|
/** Size variant of the component. */
|
|
55
55
|
size?: ThumbnailSize;
|
|
56
56
|
/** Image loading mode. */
|
|
@@ -63,6 +63,10 @@ export interface ThumbnailProps extends GenericProps {
|
|
|
63
63
|
theme?: Theme;
|
|
64
64
|
/** Variant of the component. */
|
|
65
65
|
variant?: ThumbnailVariant;
|
|
66
|
+
/** Props to pass to the link wrapping the thumbnail. */
|
|
67
|
+
linkProps?: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
|
|
68
|
+
/** Custom react component for the link (can be used to inject react router Link). */
|
|
69
|
+
linkAs?: 'a' | any;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/**
|
|
@@ -79,7 +83,7 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
|
|
|
79
83
|
* Component default props.
|
|
80
84
|
*/
|
|
81
85
|
const DEFAULT_PROPS: Partial<ThumbnailProps> = {
|
|
82
|
-
fallback:
|
|
86
|
+
fallback: mdiImageBroken,
|
|
83
87
|
loading: 'lazy',
|
|
84
88
|
theme: Theme.light,
|
|
85
89
|
};
|
|
@@ -95,7 +99,7 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
|
|
|
95
99
|
const {
|
|
96
100
|
align,
|
|
97
101
|
alt,
|
|
98
|
-
aspectRatio,
|
|
102
|
+
aspectRatio = AspectRatio.original,
|
|
99
103
|
badge,
|
|
100
104
|
className,
|
|
101
105
|
crossOrigin,
|
|
@@ -105,71 +109,104 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
|
|
|
105
109
|
image,
|
|
106
110
|
imgProps,
|
|
107
111
|
imgRef: propImgRef,
|
|
112
|
+
isLoading: isLoadingProp,
|
|
108
113
|
loading,
|
|
109
114
|
size,
|
|
110
115
|
theme,
|
|
111
116
|
variant,
|
|
117
|
+
linkProps,
|
|
118
|
+
linkAs,
|
|
112
119
|
...forwardedProps
|
|
113
120
|
} = props;
|
|
114
|
-
const
|
|
121
|
+
const [imgElement, setImgElement] = useState<HTMLImageElement>();
|
|
115
122
|
|
|
116
123
|
// Image loading state.
|
|
117
|
-
const loadingState = useImageLoad(
|
|
124
|
+
const loadingState = useImageLoad(image, imgElement);
|
|
125
|
+
const isLoaded = loadingState === 'isLoaded';
|
|
126
|
+
const isLoading = isLoadingProp || loadingState === 'isLoading';
|
|
118
127
|
const hasError = loadingState === 'hasError';
|
|
119
|
-
const isLoading = loadingState === 'isLoading';
|
|
120
128
|
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
...forwardedProps,
|
|
124
|
-
ref: mergeRefs(setWrapper, ref),
|
|
125
|
-
className: classNames(
|
|
126
|
-
className,
|
|
127
|
-
handleBasicClasses({ align, aspectRatio, prefix: CLASSNAME, size, theme, variant, hasBadge: !!badge }),
|
|
128
|
-
isLoading && wrapper?.getBoundingClientRect()?.height && 'lumx-color-background-dark-L6',
|
|
129
|
-
fillHeight && `${CLASSNAME}--fill-height`,
|
|
130
|
-
),
|
|
131
|
-
// Handle clickable Thumbnail a11y.
|
|
132
|
-
...useClickable(props),
|
|
133
|
-
};
|
|
129
|
+
// Focus point.
|
|
130
|
+
const focusPointStyle = useFocusPointStyle(props, imgElement, isLoaded);
|
|
134
131
|
|
|
135
|
-
|
|
136
|
-
const
|
|
132
|
+
const hasIconErrorFallback = hasError && typeof fallback === 'string';
|
|
133
|
+
const hasCustomErrorFallback = hasError && !hasIconErrorFallback;
|
|
134
|
+
const imageErrorStyle: CSSProperties = {};
|
|
135
|
+
if (hasIconErrorFallback) {
|
|
136
|
+
// Keep the image layout on icon fallback.
|
|
137
|
+
imageErrorStyle.visibility = 'hidden';
|
|
138
|
+
} else if (hasCustomErrorFallback) {
|
|
139
|
+
// Remove the image on custom fallback.
|
|
140
|
+
imageErrorStyle.display = 'none';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const isLink = Boolean(linkProps?.href || linkAs);
|
|
144
|
+
const isButton = !!forwardedProps.onClick;
|
|
145
|
+
const isClickable = isButton || isLink;
|
|
146
|
+
|
|
147
|
+
let Wrapper: any = 'div';
|
|
148
|
+
const wrapperProps = { ...forwardedProps };
|
|
149
|
+
if (isLink) {
|
|
150
|
+
Wrapper = linkAs || 'a';
|
|
151
|
+
Object.assign(wrapperProps, linkProps);
|
|
152
|
+
} else if (isButton) {
|
|
153
|
+
Wrapper = 'button';
|
|
154
|
+
wrapperProps.type = forwardedProps.type || 'button';
|
|
155
|
+
wrapperProps['aria-label'] = forwardedProps['aria-label'] || alt;
|
|
156
|
+
}
|
|
137
157
|
|
|
138
158
|
return (
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
159
|
+
<Wrapper
|
|
160
|
+
{...wrapperProps}
|
|
161
|
+
ref={ref}
|
|
162
|
+
className={classNames(
|
|
163
|
+
linkProps?.className,
|
|
164
|
+
className,
|
|
165
|
+
handleBasicClasses({
|
|
166
|
+
align,
|
|
167
|
+
aspectRatio,
|
|
168
|
+
prefix: CLASSNAME,
|
|
169
|
+
size,
|
|
170
|
+
theme,
|
|
171
|
+
variant,
|
|
172
|
+
isClickable,
|
|
173
|
+
hasError,
|
|
174
|
+
hasIconErrorFallback,
|
|
175
|
+
hasCustomErrorFallback,
|
|
176
|
+
isLoading,
|
|
177
|
+
hasBadge: !!badge,
|
|
178
|
+
}),
|
|
179
|
+
fillHeight && `${CLASSNAME}--fill-height`,
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
<div className={`${CLASSNAME}__background`}>
|
|
150
183
|
<img
|
|
151
184
|
{...imgProps}
|
|
152
185
|
style={{
|
|
153
186
|
...imgProps?.style,
|
|
154
|
-
...
|
|
187
|
+
...imageErrorStyle,
|
|
188
|
+
...focusPointStyle,
|
|
155
189
|
}}
|
|
156
|
-
ref={mergeRefs(
|
|
157
|
-
className={
|
|
158
|
-
crossOrigin={crossOrigin
|
|
190
|
+
ref={mergeRefs(setImgElement, propImgRef)}
|
|
191
|
+
className={classNames(`${CLASSNAME}__image`, isLoading && `${CLASSNAME}__image--is-loading`)}
|
|
192
|
+
crossOrigin={crossOrigin}
|
|
159
193
|
src={image}
|
|
160
194
|
alt={alt}
|
|
161
195
|
loading={loading}
|
|
162
196
|
/>
|
|
197
|
+
{!isLoading && hasError && (
|
|
198
|
+
<div className={`${CLASSNAME}__fallback`}>
|
|
199
|
+
{hasIconErrorFallback ? (
|
|
200
|
+
<Icon icon={fallback as string} size={Size.xxs} theme={theme} />
|
|
201
|
+
) : (
|
|
202
|
+
fallback
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
163
206
|
</div>
|
|
164
|
-
{hasError &&
|
|
165
|
-
(typeof fallback === 'string' ? (
|
|
166
|
-
<Icon className={`${CLASSNAME}__fallback`} icon={fallback} size={size || Size.m} theme={theme} />
|
|
167
|
-
) : (
|
|
168
|
-
<div className={`${CLASSNAME}__fallback`}>{fallback}</div>
|
|
169
|
-
))}
|
|
170
207
|
{badge &&
|
|
171
208
|
React.cloneElement(badge, { className: classNames(`${CLASSNAME}__badge`, badge.props.className) })}
|
|
172
|
-
</
|
|
209
|
+
</Wrapper>
|
|
173
210
|
);
|
|
174
211
|
});
|
|
175
212
|
Thumbnail.displayName = COMPONENT_NAME;
|
|
@@ -1,123 +1,76 @@
|
|
|
1
1
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
2
|
|
|
3
3
|
exports[`<Thumbnail> Snapshots and structure should render story 'Clickable' 1`] = `
|
|
4
|
-
<
|
|
5
|
-
|
|
4
|
+
<button
|
|
5
|
+
aria-label="Click me"
|
|
6
|
+
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-original lumx-thumbnail--size-xxl lumx-thumbnail--theme-light lumx-thumbnail--is-clickable lumx-thumbnail--is-loading"
|
|
6
7
|
onClick={[Function]}
|
|
7
|
-
|
|
8
|
-
role="button"
|
|
9
|
-
tabIndex={0}
|
|
8
|
+
type="button"
|
|
10
9
|
>
|
|
11
10
|
<div
|
|
12
11
|
className="lumx-thumbnail__background"
|
|
13
|
-
style={
|
|
14
|
-
Object {
|
|
15
|
-
"display": undefined,
|
|
16
|
-
"visibility": "hidden",
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
12
|
>
|
|
20
13
|
<img
|
|
21
14
|
alt="Click me"
|
|
22
|
-
className="lumx-thumbnail__image"
|
|
15
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
23
16
|
loading="lazy"
|
|
24
17
|
src="/demo-assets/landscape1.jpg"
|
|
25
18
|
style={Object {}}
|
|
26
19
|
/>
|
|
27
20
|
</div>
|
|
28
|
-
</
|
|
21
|
+
</button>
|
|
29
22
|
`;
|
|
30
23
|
|
|
31
|
-
exports[`<Thumbnail> Snapshots and structure should render story '
|
|
32
|
-
<
|
|
33
|
-
className="lumx-thumbnail lumx-thumbnail--theme-light"
|
|
24
|
+
exports[`<Thumbnail> Snapshots and structure should render story 'ClickableCustomLink' 1`] = `
|
|
25
|
+
<CustomLink
|
|
26
|
+
className="custom-class-name lumx-thumbnail lumx-thumbnail--aspect-ratio-original lumx-thumbnail--size-xxl lumx-thumbnail--theme-light lumx-thumbnail--is-clickable lumx-thumbnail--is-loading"
|
|
27
|
+
href="https://google.fr"
|
|
34
28
|
>
|
|
35
29
|
<div
|
|
36
30
|
className="lumx-thumbnail__background"
|
|
37
|
-
style={
|
|
38
|
-
Object {
|
|
39
|
-
"display": undefined,
|
|
40
|
-
"visibility": "hidden",
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
31
|
>
|
|
44
32
|
<img
|
|
45
|
-
alt="
|
|
46
|
-
className="lumx-thumbnail__image"
|
|
47
|
-
loading="lazy"
|
|
48
|
-
src="foo"
|
|
49
|
-
style={Object {}}
|
|
50
|
-
/>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
`;
|
|
54
|
-
|
|
55
|
-
exports[`<Thumbnail> Snapshots and structure should render story 'Default' 1`] = `
|
|
56
|
-
<div
|
|
57
|
-
className="lumx-thumbnail lumx-thumbnail--size-xxl lumx-thumbnail--theme-light"
|
|
58
|
-
>
|
|
59
|
-
<div
|
|
60
|
-
className="lumx-thumbnail__background"
|
|
61
|
-
style={
|
|
62
|
-
Object {
|
|
63
|
-
"display": undefined,
|
|
64
|
-
"visibility": "hidden",
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
>
|
|
68
|
-
<img
|
|
69
|
-
alt="Image alt text"
|
|
70
|
-
className="lumx-thumbnail__image"
|
|
33
|
+
alt="Click me"
|
|
34
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
71
35
|
loading="lazy"
|
|
72
36
|
src="/demo-assets/landscape1.jpg"
|
|
73
37
|
style={Object {}}
|
|
74
38
|
/>
|
|
75
39
|
</div>
|
|
76
|
-
</
|
|
40
|
+
</CustomLink>
|
|
77
41
|
`;
|
|
78
42
|
|
|
79
|
-
exports[`<Thumbnail> Snapshots and structure should render story '
|
|
80
|
-
<
|
|
81
|
-
className="lumx-thumbnail lumx-thumbnail--theme-light"
|
|
43
|
+
exports[`<Thumbnail> Snapshots and structure should render story 'ClickableLink' 1`] = `
|
|
44
|
+
<a
|
|
45
|
+
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-original lumx-thumbnail--size-xxl lumx-thumbnail--theme-light lumx-thumbnail--is-clickable lumx-thumbnail--is-loading"
|
|
46
|
+
href="https://google.fr"
|
|
82
47
|
>
|
|
83
48
|
<div
|
|
84
49
|
className="lumx-thumbnail__background"
|
|
85
|
-
style={
|
|
86
|
-
Object {
|
|
87
|
-
"display": undefined,
|
|
88
|
-
"visibility": "hidden",
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
50
|
>
|
|
92
51
|
<img
|
|
93
|
-
alt="
|
|
94
|
-
className="lumx-thumbnail__image"
|
|
52
|
+
alt="Click me"
|
|
53
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
95
54
|
loading="lazy"
|
|
96
|
-
src="
|
|
55
|
+
src="/demo-assets/landscape1.jpg"
|
|
97
56
|
style={Object {}}
|
|
98
57
|
/>
|
|
99
58
|
</div>
|
|
100
|
-
</
|
|
59
|
+
</a>
|
|
101
60
|
`;
|
|
102
61
|
|
|
103
|
-
exports[`<Thumbnail> Snapshots and structure should render story '
|
|
62
|
+
exports[`<Thumbnail> Snapshots and structure should render story 'Default' 1`] = `
|
|
104
63
|
<div
|
|
105
|
-
className="lumx-thumbnail lumx-thumbnail--theme-light"
|
|
64
|
+
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-original lumx-thumbnail--theme-light lumx-thumbnail--variant-squared lumx-thumbnail--is-loading"
|
|
106
65
|
>
|
|
107
66
|
<div
|
|
108
67
|
className="lumx-thumbnail__background"
|
|
109
|
-
style={
|
|
110
|
-
Object {
|
|
111
|
-
"display": undefined,
|
|
112
|
-
"visibility": "hidden",
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
68
|
>
|
|
116
69
|
<img
|
|
117
|
-
alt="
|
|
118
|
-
className="lumx-thumbnail__image"
|
|
70
|
+
alt="Image alt text"
|
|
71
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
119
72
|
loading="lazy"
|
|
120
|
-
src="
|
|
73
|
+
src="/demo-assets/landscape1.jpg"
|
|
121
74
|
style={Object {}}
|
|
122
75
|
/>
|
|
123
76
|
</div>
|
|
@@ -126,20 +79,14 @@ exports[`<Thumbnail> Snapshots and structure should render story 'IconFallback'
|
|
|
126
79
|
|
|
127
80
|
exports[`<Thumbnail> Snapshots and structure should render story 'WithBadge' 1`] = `
|
|
128
81
|
<div
|
|
129
|
-
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-square lumx-thumbnail--size-l lumx-thumbnail--theme-light lumx-thumbnail--variant-rounded lumx-thumbnail--has-badge"
|
|
82
|
+
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-square lumx-thumbnail--size-l lumx-thumbnail--theme-light lumx-thumbnail--variant-rounded lumx-thumbnail--is-loading lumx-thumbnail--has-badge"
|
|
130
83
|
>
|
|
131
84
|
<div
|
|
132
85
|
className="lumx-thumbnail__background"
|
|
133
|
-
style={
|
|
134
|
-
Object {
|
|
135
|
-
"display": undefined,
|
|
136
|
-
"visibility": "hidden",
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
86
|
>
|
|
140
87
|
<img
|
|
141
88
|
alt="Image alt text"
|
|
142
|
-
className="lumx-thumbnail__image"
|
|
89
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
143
90
|
loading="lazy"
|
|
144
91
|
src="/demo-assets/landscape1.jpg"
|
|
145
92
|
style={Object {}}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CSSProperties, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { AspectRatio } from '@lumx/react/components';
|
|
3
|
+
import { ThumbnailProps } from '@lumx/react/components/thumbnail/Thumbnail';
|
|
4
|
+
|
|
5
|
+
// Calculate shift to center the focus point in the container.
|
|
6
|
+
function shiftPosition(scale: number, focusPoint: number, imageSize: number, containerSize: number) {
|
|
7
|
+
const scaledSize = imageSize / scale;
|
|
8
|
+
const scaledFocusHeight = focusPoint * scaledSize;
|
|
9
|
+
const startFocus = scaledFocusHeight - containerSize / 2;
|
|
10
|
+
const shift = startFocus / (scaledSize - containerSize);
|
|
11
|
+
return Math.floor(Math.max(Math.min(shift, 1), 0) * 100);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Size = { width: number; height: number };
|
|
15
|
+
|
|
16
|
+
// Compute CSS properties to apply the focus point.
|
|
17
|
+
export const useFocusPointStyle = (
|
|
18
|
+
{ image, aspectRatio, focusPoint, imgProps: { width, height } = {} }: ThumbnailProps,
|
|
19
|
+
element: HTMLImageElement | undefined,
|
|
20
|
+
isLoaded: boolean,
|
|
21
|
+
): CSSProperties => {
|
|
22
|
+
// Get natural image size from imgProps or img element.
|
|
23
|
+
const imageSize: Size | undefined = useMemo(() => {
|
|
24
|
+
// Focus point is not applicable => exit early
|
|
25
|
+
if (!image || aspectRatio === AspectRatio.original || (!focusPoint?.x && !focusPoint?.y)) return undefined;
|
|
26
|
+
if (typeof width === 'number' && typeof height === 'number') return { width, height };
|
|
27
|
+
if (element && isLoaded) return { width: element.naturalWidth, height: element.naturalHeight };
|
|
28
|
+
return undefined;
|
|
29
|
+
}, [aspectRatio, element, focusPoint?.x, focusPoint?.y, height, image, isLoaded, width]);
|
|
30
|
+
|
|
31
|
+
// Get container size (dependant on imageSize).
|
|
32
|
+
const [containerSize, setContainerSize] = useState<Size | undefined>(undefined);
|
|
33
|
+
useEffect(
|
|
34
|
+
function updateContainerSize() {
|
|
35
|
+
const cWidth = element?.offsetWidth;
|
|
36
|
+
const cHeight = element?.offsetHeight;
|
|
37
|
+
if (cWidth && cHeight) {
|
|
38
|
+
// Update only if needed.
|
|
39
|
+
setContainerSize((oldContainerSize) =>
|
|
40
|
+
oldContainerSize?.width === cWidth && oldContainerSize.height === cHeight
|
|
41
|
+
? oldContainerSize
|
|
42
|
+
: { width: cWidth, height: cHeight },
|
|
43
|
+
);
|
|
44
|
+
} else if (imageSize) {
|
|
45
|
+
// Wait for a render (in case the container size is dependent on the image size).
|
|
46
|
+
requestAnimationFrame(updateContainerSize);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[element?.offsetHeight, element?.offsetWidth, imageSize],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Compute style.
|
|
53
|
+
const [style, setStyle] = useState<CSSProperties>({});
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
// Focus point is not applicable => exit early
|
|
56
|
+
if (!image || aspectRatio === AspectRatio.original || (!focusPoint?.x && !focusPoint?.y)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!element || !imageSize) {
|
|
60
|
+
// Focus point can be computed but now right now (image size unknown).
|
|
61
|
+
setStyle({ visibility: 'hidden' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!containerSize) {
|
|
65
|
+
// Missing container size abort focus point compute.
|
|
66
|
+
setStyle({});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const heightScale = imageSize.height / containerSize.height;
|
|
71
|
+
const widthScale = imageSize.width / containerSize.width;
|
|
72
|
+
const scale = Math.min(widthScale, heightScale);
|
|
73
|
+
|
|
74
|
+
// Focus Y relative to the top (instead of the center)
|
|
75
|
+
const focusPointFromTop = Math.abs((focusPoint?.y || 0) - 1) / 2;
|
|
76
|
+
const y = shiftPosition(scale, focusPointFromTop, imageSize.height, containerSize.height);
|
|
77
|
+
|
|
78
|
+
// Focus X relative to the left (instead of the center)
|
|
79
|
+
const focusPointFromLeft = Math.abs((focusPoint?.x || 0) + 1) / 2;
|
|
80
|
+
const x = shiftPosition(scale, focusPointFromLeft, imageSize.width, containerSize.width);
|
|
81
|
+
|
|
82
|
+
const objectPosition = `${x}% ${y}%`;
|
|
83
|
+
|
|
84
|
+
// Update only if needed.
|
|
85
|
+
setStyle((oldStyle) => (oldStyle.objectPosition === objectPosition ? oldStyle : { objectPosition }));
|
|
86
|
+
}, [aspectRatio, containerSize, element, focusPoint?.x, focusPoint?.y, image, imageSize]);
|
|
87
|
+
|
|
88
|
+
return style;
|
|
89
|
+
};
|
|
@@ -1,39 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
export type LoadingState = 'isLoading' | 'isLoaded' | 'hasError';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
function getState(img: HTMLImageElement | null | undefined, event?: Event) {
|
|
6
|
+
// Error event occurred or image loaded empty.
|
|
7
|
+
if (event?.type === 'error' || (img?.complete && (img?.naturalWidth === 0 || img?.naturalHeight === 0))) {
|
|
8
|
+
return 'hasError';
|
|
9
|
+
}
|
|
10
|
+
// Image is undefined or incomplete.
|
|
11
|
+
if (!img || !img.complete) {
|
|
12
|
+
return 'isLoading';
|
|
13
|
+
}
|
|
14
|
+
// Else loaded.
|
|
15
|
+
return 'isLoaded';
|
|
16
|
+
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
18
|
+
export function useImageLoad(imageURL: string, imgRef?: HTMLImageElement): LoadingState {
|
|
19
|
+
const [state, setState] = useState<LoadingState>(getState(imgRef));
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
);
|
|
21
|
+
// Update state when changing image URL or DOM reference.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setState(getState(imgRef));
|
|
24
|
+
}, [imageURL, imgRef]);
|
|
25
25
|
|
|
26
|
+
// Listen to `load` and `error` event on image
|
|
26
27
|
useEffect(() => {
|
|
27
|
-
const img = imgRef
|
|
28
|
+
const img = imgRef;
|
|
28
29
|
if (!img) return undefined;
|
|
29
|
-
|
|
30
|
-
update();
|
|
30
|
+
const update = (event?: Event) => setState(getState(img, event));
|
|
31
31
|
img.addEventListener('load', update);
|
|
32
32
|
img.addEventListener('error', update);
|
|
33
33
|
return () => {
|
|
34
34
|
img.removeEventListener('load', update);
|
|
35
35
|
img.removeEventListener('error', update);
|
|
36
36
|
};
|
|
37
|
-
}, [
|
|
37
|
+
}, [imgRef, imgRef?.src]);
|
|
38
|
+
|
|
38
39
|
return state;
|
|
39
40
|
}
|
|
@@ -76,13 +76,16 @@ export const EmptyTooltip = () => (
|
|
|
76
76
|
);
|
|
77
77
|
|
|
78
78
|
export const TooltipWithDropdown = () => {
|
|
79
|
-
const
|
|
79
|
+
const [button, setButton] = useState<HTMLElement | null>(null);
|
|
80
|
+
const [isOpen, setOpen] = useState(false);
|
|
80
81
|
return (
|
|
81
82
|
<>
|
|
82
|
-
<Tooltip label=
|
|
83
|
-
<Button ref={
|
|
83
|
+
<Tooltip label={!isOpen && 'Tooltip'} placement="top">
|
|
84
|
+
<Button ref={setButton} onClick={() => setOpen((o) => !o)}>
|
|
85
|
+
Anchor
|
|
86
|
+
</Button>
|
|
84
87
|
</Tooltip>
|
|
85
|
-
<Dropdown anchorRef={
|
|
88
|
+
<Dropdown anchorRef={{ current: button }} isOpen={isOpen}>
|
|
86
89
|
Dropdown
|
|
87
90
|
</Dropdown>
|
|
88
91
|
</>
|
|
@@ -28,9 +28,7 @@ export const useInjectTooltipRef = (
|
|
|
28
28
|
get(children, 'props.isDisabled') !== true
|
|
29
29
|
) {
|
|
30
30
|
const element = children as any;
|
|
31
|
-
|
|
32
|
-
setAnchorElement(element.ref.current);
|
|
33
|
-
}
|
|
31
|
+
|
|
34
32
|
return cloneElement(element, {
|
|
35
33
|
...element.props,
|
|
36
34
|
...ariaProps,
|