@lumx/react 2.1.9 → 2.2.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/esm/_internal/Avatar2.js +2 -1
- 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 +179 -782
- package/esm/_internal/Thumbnail2.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 +0 -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.tsx +1 -0
- package/src/components/avatar/__snapshots__/Avatar.test.tsx.snap +30 -30
- 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 +427 -52
- package/src/components/thumbnail/Thumbnail.test.tsx +14 -2
- package/src/components/thumbnail/Thumbnail.tsx +82 -47
- package/src/components/thumbnail/__snapshots__/Thumbnail.test.tsx.snap +26 -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/stories/generated/List/Demos.stories.tsx +2 -0
- package/src/stories/knobs/focusKnob.ts +1 -1
- package/src/stories/knobs/image.ts +35 -3
- package/types.d.ts +7 -0
- 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,102 @@ 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
|
+
}
|
|
137
155
|
|
|
138
156
|
return (
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
<Wrapper
|
|
158
|
+
{...wrapperProps}
|
|
159
|
+
ref={ref}
|
|
160
|
+
className={classNames(
|
|
161
|
+
linkProps?.className,
|
|
162
|
+
className,
|
|
163
|
+
handleBasicClasses({
|
|
164
|
+
align,
|
|
165
|
+
aspectRatio,
|
|
166
|
+
prefix: CLASSNAME,
|
|
167
|
+
size,
|
|
168
|
+
theme,
|
|
169
|
+
variant,
|
|
170
|
+
isClickable,
|
|
171
|
+
hasError,
|
|
172
|
+
hasIconErrorFallback,
|
|
173
|
+
hasCustomErrorFallback,
|
|
174
|
+
isLoading,
|
|
175
|
+
hasBadge: !!badge,
|
|
176
|
+
}),
|
|
177
|
+
fillHeight && `${CLASSNAME}--fill-height`,
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<div className={`${CLASSNAME}__background`}>
|
|
150
181
|
<img
|
|
151
182
|
{...imgProps}
|
|
152
183
|
style={{
|
|
153
184
|
...imgProps?.style,
|
|
154
|
-
...
|
|
185
|
+
...imageErrorStyle,
|
|
186
|
+
...focusPointStyle,
|
|
155
187
|
}}
|
|
156
|
-
ref={mergeRefs(
|
|
157
|
-
className={
|
|
158
|
-
crossOrigin={crossOrigin
|
|
188
|
+
ref={mergeRefs(setImgElement, propImgRef)}
|
|
189
|
+
className={classNames(`${CLASSNAME}__image`, isLoading && `${CLASSNAME}__image--is-loading`)}
|
|
190
|
+
crossOrigin={crossOrigin}
|
|
159
191
|
src={image}
|
|
160
192
|
alt={alt}
|
|
161
193
|
loading={loading}
|
|
162
194
|
/>
|
|
195
|
+
{!isLoading && hasError && (
|
|
196
|
+
<div className={`${CLASSNAME}__fallback`}>
|
|
197
|
+
{hasIconErrorFallback ? (
|
|
198
|
+
<Icon icon={fallback as string} size={Size.xxs} theme={theme} />
|
|
199
|
+
) : (
|
|
200
|
+
fallback
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
163
204
|
</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
205
|
{badge &&
|
|
171
206
|
React.cloneElement(badge, { className: classNames(`${CLASSNAME}__badge`, badge.props.className) })}
|
|
172
|
-
</
|
|
207
|
+
</Wrapper>
|
|
173
208
|
);
|
|
174
209
|
});
|
|
175
210
|
Thumbnail.displayName = COMPONENT_NAME;
|
|
@@ -1,123 +1,74 @@
|
|
|
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
|
-
className="lumx-thumbnail lumx-thumbnail--size-xxl lumx-thumbnail--theme-light"
|
|
4
|
+
<button
|
|
5
|
+
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
6
|
onClick={[Function]}
|
|
7
|
-
onKeyPress={[Function]}
|
|
8
|
-
role="button"
|
|
9
|
-
tabIndex={0}
|
|
10
7
|
>
|
|
11
8
|
<div
|
|
12
9
|
className="lumx-thumbnail__background"
|
|
13
|
-
style={
|
|
14
|
-
Object {
|
|
15
|
-
"display": undefined,
|
|
16
|
-
"visibility": "hidden",
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
10
|
>
|
|
20
11
|
<img
|
|
21
12
|
alt="Click me"
|
|
22
|
-
className="lumx-thumbnail__image"
|
|
13
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
23
14
|
loading="lazy"
|
|
24
15
|
src="/demo-assets/landscape1.jpg"
|
|
25
16
|
style={Object {}}
|
|
26
17
|
/>
|
|
27
18
|
</div>
|
|
28
|
-
</
|
|
19
|
+
</button>
|
|
29
20
|
`;
|
|
30
21
|
|
|
31
|
-
exports[`<Thumbnail> Snapshots and structure should render story '
|
|
32
|
-
<
|
|
33
|
-
className="lumx-thumbnail lumx-thumbnail--theme-light"
|
|
22
|
+
exports[`<Thumbnail> Snapshots and structure should render story 'ClickableCustomLink' 1`] = `
|
|
23
|
+
<CustomLinkComponent
|
|
24
|
+
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"
|
|
25
|
+
href="https://google.fr"
|
|
34
26
|
>
|
|
35
27
|
<div
|
|
36
28
|
className="lumx-thumbnail__background"
|
|
37
|
-
style={
|
|
38
|
-
Object {
|
|
39
|
-
"display": undefined,
|
|
40
|
-
"visibility": "hidden",
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
29
|
>
|
|
44
30
|
<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"
|
|
31
|
+
alt="Click me"
|
|
32
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
71
33
|
loading="lazy"
|
|
72
34
|
src="/demo-assets/landscape1.jpg"
|
|
73
35
|
style={Object {}}
|
|
74
36
|
/>
|
|
75
37
|
</div>
|
|
76
|
-
</
|
|
38
|
+
</CustomLinkComponent>
|
|
77
39
|
`;
|
|
78
40
|
|
|
79
|
-
exports[`<Thumbnail> Snapshots and structure should render story '
|
|
80
|
-
<
|
|
81
|
-
className="lumx-thumbnail lumx-thumbnail--theme-light"
|
|
41
|
+
exports[`<Thumbnail> Snapshots and structure should render story 'ClickableLink' 1`] = `
|
|
42
|
+
<a
|
|
43
|
+
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-original lumx-thumbnail--size-xxl lumx-thumbnail--theme-light lumx-thumbnail--is-clickable lumx-thumbnail--is-loading"
|
|
44
|
+
href="https://google.fr"
|
|
82
45
|
>
|
|
83
46
|
<div
|
|
84
47
|
className="lumx-thumbnail__background"
|
|
85
|
-
style={
|
|
86
|
-
Object {
|
|
87
|
-
"display": undefined,
|
|
88
|
-
"visibility": "hidden",
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
48
|
>
|
|
92
49
|
<img
|
|
93
|
-
alt="
|
|
94
|
-
className="lumx-thumbnail__image"
|
|
50
|
+
alt="Click me"
|
|
51
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
95
52
|
loading="lazy"
|
|
96
|
-
src="
|
|
53
|
+
src="/demo-assets/landscape1.jpg"
|
|
97
54
|
style={Object {}}
|
|
98
55
|
/>
|
|
99
56
|
</div>
|
|
100
|
-
</
|
|
57
|
+
</a>
|
|
101
58
|
`;
|
|
102
59
|
|
|
103
|
-
exports[`<Thumbnail> Snapshots and structure should render story '
|
|
60
|
+
exports[`<Thumbnail> Snapshots and structure should render story 'Default' 1`] = `
|
|
104
61
|
<div
|
|
105
|
-
className="lumx-thumbnail lumx-thumbnail--theme-light"
|
|
62
|
+
className="lumx-thumbnail lumx-thumbnail--aspect-ratio-original lumx-thumbnail--theme-light lumx-thumbnail--variant-squared lumx-thumbnail--is-loading"
|
|
106
63
|
>
|
|
107
64
|
<div
|
|
108
65
|
className="lumx-thumbnail__background"
|
|
109
|
-
style={
|
|
110
|
-
Object {
|
|
111
|
-
"display": undefined,
|
|
112
|
-
"visibility": "hidden",
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
66
|
>
|
|
116
67
|
<img
|
|
117
|
-
alt="
|
|
118
|
-
className="lumx-thumbnail__image"
|
|
68
|
+
alt="Image alt text"
|
|
69
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
119
70
|
loading="lazy"
|
|
120
|
-
src="
|
|
71
|
+
src="/demo-assets/landscape1.jpg"
|
|
121
72
|
style={Object {}}
|
|
122
73
|
/>
|
|
123
74
|
</div>
|
|
@@ -126,20 +77,14 @@ exports[`<Thumbnail> Snapshots and structure should render story 'IconFallback'
|
|
|
126
77
|
|
|
127
78
|
exports[`<Thumbnail> Snapshots and structure should render story 'WithBadge' 1`] = `
|
|
128
79
|
<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"
|
|
80
|
+
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
81
|
>
|
|
131
82
|
<div
|
|
132
83
|
className="lumx-thumbnail__background"
|
|
133
|
-
style={
|
|
134
|
-
Object {
|
|
135
|
-
"display": undefined,
|
|
136
|
-
"visibility": "hidden",
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
84
|
>
|
|
140
85
|
<img
|
|
141
86
|
alt="Image alt text"
|
|
142
|
-
className="lumx-thumbnail__image"
|
|
87
|
+
className="lumx-thumbnail__image lumx-thumbnail__image--is-loading"
|
|
143
88
|
loading="lazy"
|
|
144
89
|
src="/demo-assets/landscape1.jpg"
|
|
145
90
|
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
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export default { title: 'LumX components/list/List Demos' };
|
|
5
5
|
|
|
6
6
|
export { App as Big } from './big';
|
|
7
|
+
export { App as Clickable } from './clickable';
|
|
7
8
|
export { App as Huge } from './huge';
|
|
9
|
+
export { App as Paddings } from './paddings';
|
|
8
10
|
export { App as Regular } from './regular';
|
|
9
11
|
export { App as Tiny } from './tiny';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { number } from '@storybook/addon-knobs';
|
|
2
2
|
|
|
3
|
-
export const focusKnob = (name: string) => number(name,
|
|
3
|
+
export const focusKnob = (name: string, value = 0) => number(name, value, { max: 1, min: -1, range: true, step: 0.01 });
|
|
@@ -6,18 +6,19 @@ const avatar3 = '/demo-assets/avatar3.jpg';
|
|
|
6
6
|
const avatar4 = '/demo-assets/avatar4.jpg';
|
|
7
7
|
const landscape1 = '/demo-assets/landscape1.jpg';
|
|
8
8
|
const landscape2 = '/demo-assets/landscape2.jpg';
|
|
9
|
+
const landscape1s200 = '/demo-assets/landscape1-s200.jpg';
|
|
9
10
|
const landscape3 = '/demo-assets/landscape3.jpg';
|
|
10
11
|
const portrait1 = '/demo-assets/portrait1.jpg';
|
|
12
|
+
const portrait1s200 = '/demo-assets/portrait1-s200.jpg';
|
|
11
13
|
const portrait2 = '/demo-assets/portrait2.jpg';
|
|
12
14
|
const portrait3 = '/demo-assets/portrait3.jpg';
|
|
13
15
|
const square1 = '/demo-assets/square1.jpg';
|
|
14
16
|
const square2 = '/demo-assets/square2.jpg';
|
|
15
17
|
|
|
16
18
|
export const AVATAR_IMAGES = { avatar1, avatar2, avatar3, avatar4 };
|
|
17
|
-
|
|
18
19
|
export const SQUARE_IMAGES = { square1, square2 };
|
|
19
|
-
export const LANDSCAPE_IMAGES = { landscape1, landscape2, landscape3 };
|
|
20
|
-
export const PORTRAIT_IMAGES = { portrait1, portrait2, portrait3 };
|
|
20
|
+
export const LANDSCAPE_IMAGES = { landscape1, landscape1s200, landscape2, landscape3 };
|
|
21
|
+
export const PORTRAIT_IMAGES = { portrait1, portrait1s200, portrait2, portrait3 };
|
|
21
22
|
|
|
22
23
|
export const IMAGES = { ...LANDSCAPE_IMAGES, ...PORTRAIT_IMAGES, ...SQUARE_IMAGES, ...AVATAR_IMAGES };
|
|
23
24
|
|
|
@@ -35,3 +36,34 @@ export const squareImageKnob = (name = 'Image', value = Object.values(SQUARE_IMA
|
|
|
35
36
|
|
|
36
37
|
export const imageKnob = (name = 'Image', value = Object.values(IMAGES)[0], groupId?: string) =>
|
|
37
38
|
select(name, IMAGES, value, groupId);
|
|
39
|
+
|
|
40
|
+
type Size = { width: number; height: number };
|
|
41
|
+
|
|
42
|
+
export const AVATAR_IMAGE_SIZES: Record<keyof typeof AVATAR_IMAGES, Size> = {
|
|
43
|
+
avatar1: { width: 128, height: 128 },
|
|
44
|
+
avatar2: { width: 150, height: 150 },
|
|
45
|
+
avatar3: { width: 128, height: 128 },
|
|
46
|
+
avatar4: { width: 128, height: 128 },
|
|
47
|
+
};
|
|
48
|
+
export const SQUARE_IMAGE_SIZES: Record<keyof typeof SQUARE_IMAGES, Size> = {
|
|
49
|
+
square1: { width: 72, height: 72 },
|
|
50
|
+
square2: { width: 300, height: 300 },
|
|
51
|
+
};
|
|
52
|
+
export const LANDSCAPE_IMAGE_SIZES: Record<keyof typeof LANDSCAPE_IMAGES, Size> = {
|
|
53
|
+
landscape1: { width: 800, height: 546 },
|
|
54
|
+
landscape1s200: { width: 200, height: 150 },
|
|
55
|
+
landscape2: { width: 800, height: 600 },
|
|
56
|
+
landscape3: { width: 640, height: 480 },
|
|
57
|
+
};
|
|
58
|
+
export const PORTRAIT_IMAGE_SIZES: Record<keyof typeof PORTRAIT_IMAGES, Size> = {
|
|
59
|
+
portrait1: { width: 275, height: 500 },
|
|
60
|
+
portrait1s200: { width: 200, height: 364 },
|
|
61
|
+
portrait2: { width: 350, height: 500 },
|
|
62
|
+
portrait3: { width: 300, height: 500 },
|
|
63
|
+
};
|
|
64
|
+
export const IMAGE_SIZES: Record<keyof typeof IMAGES, Size> = {
|
|
65
|
+
...LANDSCAPE_IMAGE_SIZES,
|
|
66
|
+
...PORTRAIT_IMAGE_SIZES,
|
|
67
|
+
...SQUARE_IMAGE_SIZES,
|
|
68
|
+
...AVATAR_IMAGE_SIZES,
|
|
69
|
+
};
|
package/types.d.ts
CHANGED
|
@@ -1233,6 +1233,8 @@ export interface ThumbnailProps extends GenericProps {
|
|
|
1233
1233
|
imgProps?: ImgHTMLProps;
|
|
1234
1234
|
/** Reference to the native <img> element. */
|
|
1235
1235
|
imgRef?: Ref<HTMLImageElement>;
|
|
1236
|
+
/** Set to true to force the display of the loading skeleton. */
|
|
1237
|
+
isLoading?: boolean;
|
|
1236
1238
|
/** Size variant of the component. */
|
|
1237
1239
|
size?: ThumbnailSize;
|
|
1238
1240
|
/** Image loading mode. */
|
|
@@ -1245,6 +1247,10 @@ export interface ThumbnailProps extends GenericProps {
|
|
|
1245
1247
|
theme?: Theme;
|
|
1246
1248
|
/** Variant of the component. */
|
|
1247
1249
|
variant?: ThumbnailVariant;
|
|
1250
|
+
/** Props to pass to the link wrapping the thumbnail. */
|
|
1251
|
+
linkProps?: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
|
|
1252
|
+
/** Custom react component for the link (can be used to inject react router Link). */
|
|
1253
|
+
linkAs?: "a" | any;
|
|
1248
1254
|
}
|
|
1249
1255
|
/**
|
|
1250
1256
|
* Thumbnail component.
|
|
@@ -2427,6 +2433,7 @@ export interface TextFieldProps extends GenericProps {
|
|
|
2427
2433
|
* @return React element.
|
|
2428
2434
|
*/
|
|
2429
2435
|
export declare const TextField: Comp<TextFieldProps, HTMLDivElement>;
|
|
2436
|
+
export declare const useFocusPointStyle: ({ image, aspectRatio, focusPoint, imgProps: { width, height } }: ThumbnailProps, element: HTMLImageElement | undefined, isLoaded: boolean) => CSSProperties;
|
|
2430
2437
|
/**
|
|
2431
2438
|
* Defines the props of the component.
|
|
2432
2439
|
*/
|
package/esm/_internal/clamp.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clamp value in range.
|
|
3
|
-
*
|
|
4
|
-
* @param value Value to clamp.
|
|
5
|
-
* @param min Minimum value.
|
|
6
|
-
* @param max Maximum value.
|
|
7
|
-
* @return Clamped value.
|
|
8
|
-
*/
|
|
9
|
-
var clamp = function clamp(value, min, max) {
|
|
10
|
-
if (value < min) {
|
|
11
|
-
return min;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (value > max) {
|
|
15
|
-
return max;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return value;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export { clamp as c };
|
|
22
|
-
//# sourceMappingURL=clamp.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"clamp.js","sources":["../../../src/utils/clamp.ts"],"sourcesContent":["/**\n * Clamp value in range.\n *\n * @param value Value to clamp.\n * @param min Minimum value.\n * @param max Maximum value.\n * @return Clamped value.\n */\nexport const clamp = (value: number, min: number, max: number): number => {\n if (value < min) {\n return min;\n }\n if (value > max) {\n return max;\n }\n return value;\n};\n"],"names":["clamp","value","min","max"],"mappings":"AAAA;;;;;;;;IAQaA,KAAK,GAAG,SAARA,KAAQ,CAACC,KAAD,EAAgBC,GAAhB,EAA6BC,GAA7B,EAAqD;AACtE,MAAIF,KAAK,GAAGC,GAAZ,EAAiB;AACb,WAAOA,GAAP;AACH;;AACD,MAAID,KAAK,GAAGE,GAAZ,EAAiB;AACb,WAAOA,GAAP;AACH;;AACD,SAAOF,KAAP;AACH;;;;"}
|