Skip to content

Commit 206340c

Browse files
committed
Add Img component with skeleton + fallback support (#4791)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces a new `Img` component that handles image loading states with customizable skeletons and fallbacks. It also adds a Storybook story to demonstrate various usages of the `Img` component. ### Detailed summary - Added `Img` component to handle image loading states. - Implemented state management for loading, fallback, and loaded statuses. - Introduced customizable `skeleton` and `fallback` props. - Created Storybook stories showcasing different scenarios for the `Img` component. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 6153d57 commit 206340c

File tree

2 files changed

+175
-0
lines changed

2 files changed

+175
-0
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { ImageIcon } from "lucide-react";
3+
import { useState } from "react";
4+
import { BadgeContainer } from "../../../stories/utils";
5+
import { Spinner } from "../ui/Spinner/Spinner";
6+
import { Button } from "../ui/button";
7+
import { Img } from "./Img";
8+
9+
const meta = {
10+
title: "blocks/Img",
11+
component: Story,
12+
parameters: {},
13+
} satisfies Meta<typeof Story>;
14+
15+
export default meta;
16+
type Story = StoryObj<typeof meta>;
17+
18+
export const Desktop: Story = {
19+
args: {},
20+
};
21+
22+
function Story() {
23+
return (
24+
<div className="flex flex-col gap-10 p-10">
25+
<p> All images below are set with size-20 className </p>
26+
27+
<BadgeContainer label="No Src - pending">
28+
<Img className="size-20" src={undefined} />
29+
</BadgeContainer>
30+
31+
<BadgeContainer label="Failed to load - fallback">
32+
<Img className="size-20" src="invalid-src" />
33+
</BadgeContainer>
34+
35+
<BadgeContainer label="No Src, pending - rounded-full">
36+
<Img className="size-20 rounded-full" src={undefined} />
37+
</BadgeContainer>
38+
39+
<BadgeContainer label="Failed to load - fallback - rounded-full">
40+
<Img className="size-20 rounded-full" src="invalid-src" />
41+
</BadgeContainer>
42+
43+
<BadgeContainer label="Valid Src">
44+
<Img className="size-20" src="https://picsum.photos/200" />
45+
</BadgeContainer>
46+
47+
<BadgeContainer label="Valid Src, rounded">
48+
<Img className="size-20 rounded-full" src="https://picsum.photos/200" />
49+
</BadgeContainer>
50+
51+
<ToggleTest />
52+
53+
<BadgeContainer label="Custom Skeleton">
54+
<Img
55+
src={undefined}
56+
skeleton={
57+
<div className="flex items-center justify-center rounded-lg border">
58+
<Spinner className="size-6" />
59+
</div>
60+
}
61+
className="size-20"
62+
/>
63+
</BadgeContainer>
64+
65+
<BadgeContainer label="Custom Fallback">
66+
<Img
67+
fallback={
68+
<div className="flex items-center justify-center rounded-lg border">
69+
<ImageIcon className="size-10 text-muted-foreground" />
70+
</div>
71+
}
72+
className="size-20"
73+
src="invalid-src"
74+
/>
75+
</BadgeContainer>
76+
</div>
77+
);
78+
}
79+
80+
function ToggleTest() {
81+
const [src, setSrc] = useState<undefined | string>(undefined);
82+
83+
return (
84+
<div className="relative flex flex-col gap-10 border p-6">
85+
<Button
86+
variant="outline"
87+
onClick={() => {
88+
if (src) {
89+
setSrc(undefined);
90+
} else {
91+
setSrc("https://picsum.photos/400");
92+
}
93+
}}
94+
className="absolute top-6 right-6 inline-flex"
95+
>
96+
Toggle Src
97+
</Button>
98+
99+
<p> Src is {src ? "set" : "not set"} </p>
100+
101+
<BadgeContainer label="Valid Src">
102+
<Img className="size-20" src={src} />
103+
</BadgeContainer>
104+
105+
<BadgeContainer label="invalid Src">
106+
<Img
107+
className="size-20 rounded-full"
108+
src={src ? "invalid-src" : undefined}
109+
/>
110+
</BadgeContainer>
111+
</div>
112+
);
113+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* eslint-disable @next/next/no-img-element */
2+
"use client";
3+
import { useState } from "react";
4+
import { cn } from "../../lib/utils";
5+
6+
type imgElementProps = React.DetailedHTMLProps<
7+
React.ImgHTMLAttributes<HTMLImageElement>,
8+
HTMLImageElement
9+
> & {
10+
skeleton?: React.ReactNode;
11+
fallback?: React.ReactNode;
12+
src: string | undefined;
13+
};
14+
15+
export function Img(props: imgElementProps) {
16+
const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">(
17+
"pending",
18+
);
19+
const status =
20+
props.src === undefined
21+
? "pending"
22+
: props.src === ""
23+
? "fallback"
24+
: _status;
25+
const { className, fallback, skeleton, ...restProps } = props;
26+
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
27+
const defaultFallback = <div className="bg-muted" />;
28+
29+
return (
30+
<div className="relative">
31+
<img
32+
{...restProps}
33+
onLoad={() => {
34+
setStatus("loaded");
35+
}}
36+
onError={() => {
37+
setStatus("fallback");
38+
}}
39+
style={{
40+
opacity: status === "loaded" ? 1 : 0,
41+
}}
42+
alt={restProps.alt || ""}
43+
className={cn(
44+
"fade-in-0 object-cover transition-opacity duration-300",
45+
className,
46+
)}
47+
/>
48+
49+
{status !== "loaded" && (
50+
<div
51+
className={cn(
52+
"fade-in-0 absolute inset-0 overflow-hidden transition-opacity duration-300 [&>*]:h-full [&>*]:w-full",
53+
className,
54+
)}
55+
>
56+
{status === "pending" && (skeleton || defaultSkeleton)}
57+
{status === "fallback" && (fallback || defaultFallback)}
58+
</div>
59+
)}
60+
</div>
61+
);
62+
}

0 commit comments

Comments
 (0)