@grapu-design/react-image 0.1.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/lib/Image-12s-O0sy0c4i.cjs +160 -0
- package/lib/Image-12s-SR1fYJF4.js +156 -0
- package/lib/index.cjs +15 -0
- package/lib/index.d.ts +1127 -0
- package/lib/index.js +11 -0
- package/package.json +48 -0
- package/src/Image.namespace.ts +8 -0
- package/src/Image.tsx +71 -0
- package/src/index.ts +19 -0
- package/src/useImage.test.tsx +48 -0
- package/src/useImage.ts +101 -0
- package/src/useImageContext.tsx +17 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { I as ImageContent, a as ImageFallback, b as ImageRoot } from './Image-12s-SR1fYJF4.js';
|
|
2
|
+
export { u as useImage, c as useImageContext } from './Image-12s-SR1fYJF4.js';
|
|
3
|
+
|
|
4
|
+
var Image_namespace = {
|
|
5
|
+
__proto__: null,
|
|
6
|
+
Content: ImageContent,
|
|
7
|
+
Fallback: ImageFallback,
|
|
8
|
+
Root: ImageRoot
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { Image_namespace as Image, ImageContent, ImageFallback, ImageRoot };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grapu-design/react-image",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/grapu-design/designsystem.git",
|
|
7
|
+
"directory": "packages/react-headless/image"
|
|
8
|
+
},
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"import": "./lib/index.js",
|
|
15
|
+
"require": "./lib/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./lib/index.cjs",
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"clean": "rm -rf lib",
|
|
26
|
+
"build": "bunchee",
|
|
27
|
+
"lint:publish": "bun publint"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@radix-ui/react-compose-refs": "^1.1.2",
|
|
31
|
+
"@radix-ui/react-use-callback-ref": "^1.1.1",
|
|
32
|
+
"@radix-ui/react-use-layout-effect": "^1.1.1",
|
|
33
|
+
"@grapu-design/dom-utils": "^0.1.0",
|
|
34
|
+
"@grapu-design/react-primitive": "^0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "^19.1.6",
|
|
38
|
+
"react": "^19.1.0",
|
|
39
|
+
"react-dom": "^19.1.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18.0.0",
|
|
43
|
+
"react-dom": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/Image.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { composeRefs } from "@radix-ui/react-compose-refs";
|
|
4
|
+
import { useLayoutEffect } from "@radix-ui/react-use-layout-effect";
|
|
5
|
+
import { mergeProps } from "@grapu-design/dom-utils";
|
|
6
|
+
import { Primitive, type PrimitiveProps } from "@grapu-design/react-primitive";
|
|
7
|
+
import type * as React from "react";
|
|
8
|
+
import { forwardRef } from "react";
|
|
9
|
+
import { useImage, type UseImageProps } from "./useImage";
|
|
10
|
+
import { ImageProvider, useImageContext } from "./useImageContext";
|
|
11
|
+
|
|
12
|
+
export interface ImageRootProps
|
|
13
|
+
extends UseImageProps,
|
|
14
|
+
PrimitiveProps,
|
|
15
|
+
React.HTMLAttributes<HTMLDivElement> {}
|
|
16
|
+
|
|
17
|
+
export const ImageRoot = forwardRef<HTMLDivElement, ImageRootProps>((props, ref) => {
|
|
18
|
+
const { onLoadingStatusChange, ...otherProps } = props;
|
|
19
|
+
const api = useImage({ onLoadingStatusChange });
|
|
20
|
+
return (
|
|
21
|
+
<ImageProvider value={api}>
|
|
22
|
+
<Primitive.div ref={ref} {...mergeProps(api.rootProps, otherProps)} />
|
|
23
|
+
</ImageProvider>
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
ImageRoot.displayName = "ImageRoot";
|
|
27
|
+
|
|
28
|
+
export interface ImageContentProps
|
|
29
|
+
extends PrimitiveProps,
|
|
30
|
+
React.ImgHTMLAttributes<HTMLImageElement> {}
|
|
31
|
+
|
|
32
|
+
export const ImageContent = forwardRef<HTMLImageElement, ImageContentProps>((props, ref) => {
|
|
33
|
+
const { src, onLoad, onError, ...otherProps } = props;
|
|
34
|
+
|
|
35
|
+
const { refs, setSrc, getContentProps, handleLoad, handleError } = useImageContext();
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
setSrc(src);
|
|
39
|
+
}, [src, setSrc]);
|
|
40
|
+
|
|
41
|
+
const contentProps = getContentProps({ src });
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Primitive.img
|
|
45
|
+
ref={composeRefs(refs.image, ref)}
|
|
46
|
+
{...mergeProps(contentProps, otherProps, {
|
|
47
|
+
// if loading is lazy, we should not hide the image even if it's not loaded yet,
|
|
48
|
+
// because the browser should be able to check if it's in the viewport.
|
|
49
|
+
// TODO: it should be better than this; why doesn't useImage properly handle this case?
|
|
50
|
+
hidden: otherProps.loading === "lazy" ? false : contentProps.hidden,
|
|
51
|
+
})}
|
|
52
|
+
onLoad={(e) => {
|
|
53
|
+
handleLoad();
|
|
54
|
+
onLoad?.(e);
|
|
55
|
+
}}
|
|
56
|
+
onError={(e) => {
|
|
57
|
+
handleError();
|
|
58
|
+
onError?.(e);
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
ImageContent.displayName = "ImageContent";
|
|
64
|
+
|
|
65
|
+
export interface ImageFallbackProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
|
|
66
|
+
|
|
67
|
+
export const ImageFallback = forwardRef<HTMLDivElement, ImageFallbackProps>((props, ref) => {
|
|
68
|
+
const { fallbackProps } = useImageContext();
|
|
69
|
+
return <Primitive.div ref={ref} {...mergeProps(fallbackProps, props)} />;
|
|
70
|
+
});
|
|
71
|
+
ImageFallback.displayName = "ImageFallback";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ImageRoot,
|
|
3
|
+
ImageContent,
|
|
4
|
+
ImageFallback,
|
|
5
|
+
type ImageRootProps,
|
|
6
|
+
type ImageContentProps,
|
|
7
|
+
type ImageFallbackProps,
|
|
8
|
+
} from "./Image";
|
|
9
|
+
|
|
10
|
+
export { useImageContext, type UseImageContext } from "./useImageContext";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
useImage,
|
|
14
|
+
type UseImageProps,
|
|
15
|
+
type UseImageReturn,
|
|
16
|
+
type ImageLoadingStatus,
|
|
17
|
+
} from "./useImage";
|
|
18
|
+
|
|
19
|
+
export * as Image from "./Image.namespace";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import type { ReactElement } from "react";
|
|
6
|
+
|
|
7
|
+
import { useImage, type UseImageProps } from "./useImage";
|
|
8
|
+
|
|
9
|
+
const ROOT_TEST_ID = "image-root";
|
|
10
|
+
const FALLBACK_TEXT = "AB";
|
|
11
|
+
const IMAGE_ALT_TEXT = "Fake Image";
|
|
12
|
+
|
|
13
|
+
function setUp(jsx: ReactElement) {
|
|
14
|
+
return {
|
|
15
|
+
user: userEvent.setup(),
|
|
16
|
+
...render(jsx),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function Image(props: UseImageProps) {
|
|
21
|
+
const { rootProps, getContentProps, fallbackProps } = useImage(props);
|
|
22
|
+
return (
|
|
23
|
+
<div data-testid={ROOT_TEST_ID} {...rootProps}>
|
|
24
|
+
<img {...getContentProps({})} alt={IMAGE_ALT_TEXT} />
|
|
25
|
+
<span {...fallbackProps}>{FALLBACK_TEXT}</span>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("useImage", () => {
|
|
31
|
+
it("initial state is loading", () => {
|
|
32
|
+
const { getByAltText } = setUp(<Image />);
|
|
33
|
+
const image = getByAltText(IMAGE_ALT_TEXT);
|
|
34
|
+
expect(image).toHaveAttribute("data-loading-state", "loading");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should not render the image initially", () => {
|
|
38
|
+
const { getByAltText } = setUp(<Image />);
|
|
39
|
+
const image = getByAltText(IMAGE_ALT_TEXT);
|
|
40
|
+
expect(image).not.toBeVisible();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should render the fallback initially", () => {
|
|
44
|
+
const { queryByText } = setUp(<Image />);
|
|
45
|
+
const fallback = queryByText(FALLBACK_TEXT);
|
|
46
|
+
expect(fallback).toBeVisible();
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/useImage.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
|
|
2
|
+
import { useLayoutEffect } from "@radix-ui/react-use-layout-effect";
|
|
3
|
+
import { dataAttr, elementProps, imgProps } from "@grapu-design/dom-utils";
|
|
4
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
5
|
+
|
|
6
|
+
export type ImageLoadingStatus = "loading" | "loaded" | "error";
|
|
7
|
+
|
|
8
|
+
export interface UseImageProps {
|
|
9
|
+
onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type UseImageReturn = ReturnType<typeof useImage>;
|
|
13
|
+
|
|
14
|
+
export function useImage(props: UseImageProps) {
|
|
15
|
+
const onLoadingStatusChange = useCallbackRef(props.onLoadingStatusChange);
|
|
16
|
+
const [loadingStatus, setLoadingStatus] = useState<ImageLoadingStatus>("loading");
|
|
17
|
+
const imageRef = useRef<HTMLImageElement>(null);
|
|
18
|
+
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
if (imageRef.current) {
|
|
21
|
+
if (imageRef.current.complete) {
|
|
22
|
+
if (imageRef.current.naturalWidth === 0 || imageRef.current.naturalHeight === 0) {
|
|
23
|
+
setLoadingStatus("error");
|
|
24
|
+
onLoadingStatusChange?.("error");
|
|
25
|
+
} else {
|
|
26
|
+
setLoadingStatus("loaded");
|
|
27
|
+
onLoadingStatusChange?.("loaded");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}, [onLoadingStatusChange]);
|
|
32
|
+
|
|
33
|
+
const isLoaded = loadingStatus === "loaded";
|
|
34
|
+
|
|
35
|
+
const stateProps = useMemo(
|
|
36
|
+
() =>
|
|
37
|
+
elementProps({
|
|
38
|
+
"data-loading-state": loadingStatus,
|
|
39
|
+
}),
|
|
40
|
+
[loadingStatus],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const setSrc = useCallback(
|
|
44
|
+
(src: string | undefined) => {
|
|
45
|
+
if (src === undefined || src === null) {
|
|
46
|
+
setLoadingStatus("error");
|
|
47
|
+
onLoadingStatusChange?.("error");
|
|
48
|
+
} else {
|
|
49
|
+
setLoadingStatus("loading");
|
|
50
|
+
onLoadingStatusChange?.("loading");
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[onLoadingStatusChange],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const getContentProps = useCallback(
|
|
57
|
+
({ src }: { src?: string }) => {
|
|
58
|
+
return imgProps({
|
|
59
|
+
hidden: !isLoaded,
|
|
60
|
+
"data-visible": dataAttr(isLoaded),
|
|
61
|
+
src,
|
|
62
|
+
...stateProps,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
[isLoaded, stateProps],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const handleLoad = useCallback(() => {
|
|
69
|
+
setLoadingStatus("loaded");
|
|
70
|
+
onLoadingStatusChange?.("loaded");
|
|
71
|
+
}, [onLoadingStatusChange]);
|
|
72
|
+
|
|
73
|
+
const handleError = useCallback(() => {
|
|
74
|
+
setLoadingStatus("error");
|
|
75
|
+
onLoadingStatusChange?.("error");
|
|
76
|
+
}, [onLoadingStatusChange]);
|
|
77
|
+
|
|
78
|
+
const fallbackProps = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
elementProps({
|
|
81
|
+
hidden: isLoaded,
|
|
82
|
+
"data-visible": dataAttr(!isLoaded),
|
|
83
|
+
...stateProps,
|
|
84
|
+
}),
|
|
85
|
+
[isLoaded, stateProps],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
refs: {
|
|
90
|
+
image: imageRef,
|
|
91
|
+
},
|
|
92
|
+
loadingStatus,
|
|
93
|
+
stateProps,
|
|
94
|
+
rootProps: stateProps,
|
|
95
|
+
setSrc,
|
|
96
|
+
getContentProps,
|
|
97
|
+
handleLoad,
|
|
98
|
+
handleError,
|
|
99
|
+
fallbackProps,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { UseImageReturn } from "./useImage";
|
|
3
|
+
|
|
4
|
+
export interface UseImageContext extends UseImageReturn {}
|
|
5
|
+
|
|
6
|
+
const ImageContext = createContext<UseImageContext | null>(null);
|
|
7
|
+
|
|
8
|
+
export const ImageProvider = ImageContext.Provider;
|
|
9
|
+
|
|
10
|
+
export function useImageContext({ strict = true }: { strict?: boolean } = {}): UseImageContext {
|
|
11
|
+
const context = useContext(ImageContext);
|
|
12
|
+
if (!context && strict) {
|
|
13
|
+
throw new Error("useImageContext must be used within an Image");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return context as UseImageContext;
|
|
17
|
+
}
|