Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(image): add loading src to display loading state #4783

Open
wants to merge 8 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/docs/content/components/image/customLoading.raw.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Image} from "@heroui/react";

export default function App() {
return (
<Image
alt="HeroUI Image with custom loading"
height={200}
loadingSrc="https://via.placeholder.com/300x200"
src="https://app.requestly.io/delay/1000/https://nextui-docs-v2.vercel.app/images/fruit-4.jpeg"
width={300}
/>
);
}
9 changes: 9 additions & 0 deletions apps/docs/content/components/image/customLoading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import App from "./customLoading.raw.jsx?raw";

const react = {
"/App.jsx": App,
};

export default {
...react,
};
2 changes: 1 addition & 1 deletion apps/docs/content/components/image/fallback.raw.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function App() {
alt="HeroUI Image with fallback"
fallbackSrc="https://via.placeholder.com/300x200"
height={200}
src="https://app.requestly.io/delay/1000/https://heroui.com/images/fruit-4.jpeg"
src="wrong-image-address"
width={300}
/>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/components/image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import usage from "./usage";
import blurred from "./blurred";
import zoomed from "./zoomed";
import loading from "./loading";
import customLoading from "./customLoading";
import fallback from "./fallback";
import nextjs from "./nextjs";

Expand All @@ -10,6 +11,7 @@ export const imageContent = {
blurred,
zoomed,
loading,
customLoading,
fallback,
nextjs,
};
24 changes: 19 additions & 5 deletions apps/docs/content/docs/components/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,26 +54,34 @@ You can use the `isZoomed` prop make the image zoomed when hovered.

<CodeDemo title="Zoomed" files={imageContent.zoomed} />

### Animated Loading
### Animated (Skeleton) Loading

Image component has a built-in `skeleton` animation to indicate the image is loading and an
`opacity` animation when the image loads.
Image component has a built-in `skeleton` animation to indicate the image is loading in case the `loadingSrc` is not defined.

<CodeDemo displayMode="visible" title="Animated Loading" files={imageContent.loading} />

> **Note**: The `URL` uses `https://app.requestly.io/delay` to simulate a slow network.

### Custom Loading Image

You can use the `loadingSrc` prop to display a loading image when the image provided in `src` is still loading.

<CodeDemo displayMode="visible" title="Custom Loading" files={imageContent.customLoading} />

> **Note**: The `URL` uses `https://app.requestly.io/delay` to simulate a slow network.

### Image with fallback

You can use the `fallbackSrc` prop to display a fallback image when:

- The `fallbackSrc` prop is provided.
- The image provided in `src` is still loading.
- The image provided in `src` fails to load.
- The image provided in `src` is not found.

<CodeDemo displayMode="visible" title="Image with fallback" files={imageContent.fallback} />

> **Note**: You can have both `loadingSrc` and `fallbackSrc` props to cover multiple possibilities while loading and handling image errors.

### With Next.js Image

Next.js provides an optimized [Image](https://nextjs.org/docs/app/api-reference/components/image) component,
Expand Down Expand Up @@ -152,11 +160,17 @@ you can use it with HeroUI `Image` component as well.
type: "eager | lazy",
description: "A loading strategy to use for the image.",
default: "-"
},
{
attribute: "loadingSrc",
type: "string",
description: "The image source to display while the main image is loading. This helps provide visual feedback during the loading process.",
default: "-"
},
{
attribute: "fallbackSrc",
type: "string",
description: "The fallback image source.",
description: "The image source to display when the main image fails to load or encounters an error.",
default: "-"
},
{
Expand Down
128 changes: 110 additions & 18 deletions packages/components/image/__tests__/image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,44 @@ import {render, act} from "@testing-library/react";
import {Image} from "../src";

const src = "https://via.placeholder.com/300x450";
const fallbackSrc = "https://via.placeholder.com/300x450";
const fallbackSrc = "https://via.placeholder.com/300x200";
const loadingSrc = "/images/local-image-small.jpg";

describe("Image", () => {
let imageOnload: any = null;

beforeAll(() => {
function trackImageOnload() {
Object.defineProperty(window.Image.prototype, "onload", {
get() {
return this._onload;
},
set(fn) {
imageOnload = fn;
this._onload = fn;
},
configurable: true,
});
}

trackImageOnload();
});

afterAll(() => {
// Restore original Image prototype
delete window.Image.prototype._onload;
Object.defineProperty(window.Image.prototype, "onload", {
value: null,
writable: true,
configurable: true,
});
});

it("should render correctly", () => {
const wrapper = render(<Image />);

expect(() => wrapper.unmount()).not.toThrow();
wrapper.unmount();
});

it("ref should be forwarded", () => {
Expand All @@ -24,44 +55,103 @@ describe("Image", () => {
const wrapper = render(<Image fallbackSrc={fallbackSrc} src={src} />);

expect(wrapper.getByRole("img")).toBeInstanceOf(HTMLImageElement);
wrapper.unmount();
});

test("renders image if there is no fallback behavior defined", async () => {
test("renders an image while loading the src image. When loading finished, renders the src image.", async () => {
const onLoad = jest.fn();
const wrapper = render(<Image loadingSrc={loadingSrc} src={src} onLoad={onLoad} />);
const imageParent = wrapper.getByRole("img").parentElement;

expect(imageParent).not.toBeNull();
expect(imageParent!.getAttribute("data-testid")).toEqual("heroUI/image_parent");

const computedLoadingStyle = window.getComputedStyle(imageParent!);

expect(computedLoadingStyle.backgroundImage).toBe(`url(${loadingSrc})`);

act(() => {
imageOnload();
});

const computedLoadedStyle = window.getComputedStyle(imageParent!);

expect(onLoad).toHaveBeenCalled();
expect(computedLoadedStyle.backgroundImage).toBe("");
wrapper.unmount();
});

test("renders fallback source if src is wrong or not found.", async () => {
let imageOnerror: any = null;

function trackImageOnerror() {
Object.defineProperty(window.Image.prototype, "onerror", {
get() {
return this._onload;
},
set(fn) {
imageOnerror = fn;
this._onerror = fn;
},
configurable: true,
});
}

trackImageOnerror();

const cleanup = () => {
delete window.Image.prototype._onerror;
Object.defineProperty(window.Image.prototype, "onerror", {
value: null,
writable: true,
configurable: true,
});
};

const onError = jest.fn();
const wrapper = render(
<Image alt="test" fallbackSrc={fallbackSrc} src="wrong-src-address" onError={onError} />,
);
const imageParent = wrapper.getByRole("img").parentElement;

expect(imageParent).not.toBeNull();
expect(imageParent!.getAttribute("data-testid")).toEqual("heroUI/image_parent");

act(() => {
imageOnerror();
});

expect(onError).toHaveBeenCalled();
const computedStyle = window.getComputedStyle(imageParent!);

expect(computedStyle.backgroundImage).toBe(`url(${fallbackSrc})`);
wrapper.unmount();
cleanup();
});

test("renders image if there is no loading or fallback behavior defined", async () => {
const wrapper = render(<Image src={src} />);

expect(wrapper.getByRole("img")).toHaveAttribute("src", src);
wrapper.unmount();
});

test("should render a wrapper when isZoomed or isBlurred is true", () => {
const wrapper = render(<Image isBlurred isZoomed src={src} />);

expect(wrapper.getByRole("img").parentElement).toBeInstanceOf(HTMLDivElement);
wrapper.unmount();
});

test("should render a blurred image when isBlurred is true", () => {
const wrapper = render(<Image isBlurred src={src} />);
const blurredImage = wrapper.getByRole("img").nextElementSibling;

expect(blurredImage).toBeInstanceOf(HTMLImageElement);
wrapper.unmount();
});

test("should fire onload", () => {
let imageOnload: any = null;

function trackImageOnload() {
Object.defineProperty(window.Image.prototype, "onload", {
get() {
return this._onload;
},
set(fn) {
imageOnload = fn;
this._onload = fn;
},
});
}

trackImageOnload();

const onLoad = jest.fn();

const wrapper = render(<Image fallbackSrc={fallbackSrc} src={src} onLoad={onLoad} />);
Expand All @@ -72,6 +162,7 @@ describe("Image", () => {

expect(wrapper.getByRole("img")).toHaveAttribute("src", src);
expect(onLoad).toHaveBeenCalled();
wrapper.unmount();
});

test("should disable aspect ratio if height is set", () => {
Expand All @@ -92,5 +183,6 @@ describe("Image", () => {
expect(getComputedStyle(images[1]).height).toBe("40px");
expect(getComputedStyle(images[2]).height).toBe("50px");
expect(getComputedStyle(images[3]).height).toBe("60px");
wrapper.unmount();
});
});
9 changes: 7 additions & 2 deletions packages/components/image/src/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const Image = forwardRef<"img", ImageProps>((props, ref) => {
classNames,
isBlurred,
isZoomed,
loadingSrc,
fallbackSrc,
removeWrapper,
disableSkeleton,
Expand Down Expand Up @@ -45,8 +46,12 @@ const Image = forwardRef<"img", ImageProps>((props, ref) => {
}

// when zoomed or showSkeleton, we need to wrap the image
if (isZoomed || !disableSkeleton || fallbackSrc) {
return <div {...getWrapperProps()}> {isZoomed ? zoomed : img}</div>;
if (isZoomed || !disableSkeleton || loadingSrc || fallbackSrc) {
return (
<div data-testid="heroUI/image_parent" {...getWrapperProps()}>
{isZoomed ? zoomed : img}
</div>
);
}

return img;
Expand Down
26 changes: 19 additions & 7 deletions packages/components/image/src/use-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ interface Props extends HTMLHeroUIProps<"img"> {
*/
isBlurred?: boolean;
/**
* A fallback image.
* A fallback image when error encountered.
*/
fallbackSrc?: React.ReactNode;
/**
* A loading image.
*/
loadingSrc?: React.ReactNode;
/**
* Whether to disable the loading skeleton.
* @default false
Expand Down Expand Up @@ -82,9 +86,10 @@ export function useImage(originalProps: UseImageProps) {
classNames,
loading,
isBlurred,
loadingSrc,
fallbackSrc,
isLoading: isLoadingProp,
disableSkeleton = !!fallbackSrc,
disableSkeleton = !!loadingSrc,
removeWrapper = false,
onError,
onLoad,
Expand All @@ -110,6 +115,7 @@ export function useImage(originalProps: UseImageProps) {

const isImgLoaded = imageStatus === "loaded" && !isLoadingProp;
const isLoading = imageStatus === "loading" || isLoadingProp;
const isFailed = imageStatus === "failed";
const isZoomed = originalProps.isZoomed;

const Component = as || "img";
Expand All @@ -131,8 +137,9 @@ export function useImage(originalProps: UseImageProps) {
};
}, [props?.width, props?.height]);

const showFallback = (!src || !isImgLoaded) && !!fallbackSrc;
const showSkeleton = isLoading && !disableSkeleton;
const showLoading = isLoading && !!loadingSrc;
const showFallback = (isFailed || !src || !isImgLoaded) && !!fallbackSrc;
const showSkeleton = isLoading && !disableSkeleton && !loadingSrc;

const slots = useMemo(
() =>
Expand Down Expand Up @@ -170,7 +177,11 @@ export function useImage(originalProps: UseImageProps) {
};

const getWrapperProps = useCallback<PropGetter>(() => {
const fallbackStyle = showFallback
const wrapperStyle = showLoading
? {
backgroundImage: `url(${loadingSrc})`,
}
: showFallback && !showSkeleton
? {
backgroundImage: `url(${fallbackSrc})`,
}
Expand All @@ -179,11 +190,11 @@ export function useImage(originalProps: UseImageProps) {
return {
className: slots.wrapper({class: classNames?.wrapper}),
style: {
...fallbackStyle,
...wrapperStyle,
maxWidth: w,
},
};
}, [slots, showFallback, fallbackSrc, classNames?.wrapper, w]);
}, [slots, showLoading, showFallback, showSkeleton, fallbackSrc, classNames?.wrapper, w]);

const getBlurredImgProps = useCallback<PropGetter>(() => {
return {
Expand All @@ -200,6 +211,7 @@ export function useImage(originalProps: UseImageProps) {
classNames,
isBlurred,
disableSkeleton,
loadingSrc,
fallbackSrc,
removeWrapper,
isZoomed,
Expand Down
Loading
Loading