Skip to content

Commit 0cc0744

Browse files
committed
fix(react): improve image component and demos
1 parent 179d46e commit 0cc0744

12 files changed

Lines changed: 218 additions & 104 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tiny-design/react": patch
3+
---
4+
5+
Fix image loading, fallback handling, and demo behavior.

packages/react/src/image/__tests__/image.test.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import React from 'react';
2-
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import Image from '../index';
44

55
describe('<Image />', () => {
66
const originalIntersectionObserver = window.IntersectionObserver;
7+
const completeDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'complete');
8+
const naturalWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalWidth');
79

810
afterEach(() => {
911
window.IntersectionObserver = originalIntersectionObserver;
12+
if (completeDescriptor) {
13+
Object.defineProperty(HTMLImageElement.prototype, 'complete', completeDescriptor);
14+
}
15+
if (naturalWidthDescriptor) {
16+
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', naturalWidthDescriptor);
17+
}
1018
});
1119

1220
it('should match the snapshot', () => {
@@ -34,6 +42,14 @@ describe('<Image />', () => {
3442
expect(screen.getByRole('img')).toHaveStyle({ objectFit: 'contain' });
3543
});
3644

45+
it('should forward ref to the native img element', () => {
46+
const ref = React.createRef<HTMLImageElement>();
47+
48+
render(<Image src="test.jpg" ref={ref} />);
49+
50+
expect(ref.current?.tagName).toBe('IMG');
51+
});
52+
3753
it('should switch to fallback image when src fails', () => {
3854
render(<Image src="broken.jpg" fallback="fallback.jpg" alt="cover" />);
3955

@@ -58,6 +74,32 @@ describe('<Image />', () => {
5874
expect(screen.getByTestId('fallback-node')).toBeInTheDocument();
5975
});
6076

77+
it('should preserve onError and promote fallback for cached broken images', async () => {
78+
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
79+
configurable: true,
80+
get() {
81+
return true;
82+
},
83+
});
84+
85+
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
86+
configurable: true,
87+
get() {
88+
return this.getAttribute('src') === 'broken.jpg' ? 0 : 100;
89+
},
90+
});
91+
92+
const handleError = jest.fn();
93+
94+
render(<Image src="broken.jpg" fallback="fallback.jpg" onError={handleError} alt="cover" />);
95+
96+
await waitFor(() => {
97+
expect(screen.getByRole('img', { name: 'cover' })).toHaveAttribute('src', 'fallback.jpg');
98+
});
99+
100+
expect(handleError).toHaveBeenCalledTimes(1);
101+
});
102+
61103
it('should keep lazy image on placeholder before intersecting', () => {
62104
const observe = jest.fn();
63105
const unobserve = jest.fn();

packages/react/src/image/demo/Basic.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,11 @@ import React from 'react';
22
import { Image } from '@tiny-design/react';
33

44
export default function BasicDemo() {
5-
return <Image width="100%" src="https://cdn.pixabay.com/photo/2019/12/30/20/35/snow-4730565__340.jpg" />;
6-
}
5+
return (
6+
<Image
7+
width="100%"
8+
src="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&h=420&q=80"
9+
alt="Wide mountain lake"
10+
/>
11+
);
12+
}

packages/react/src/image/demo/CustomFallback.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import React from 'react';
2-
import { Image } from '@tiny-design/react';
2+
import { Flex, Image } from '@tiny-design/react';
33

44
const fallbackStyle: React.CSSProperties = {
55
width: '100%',
66
height: '100%',
7-
display: 'flex',
8-
flexDirection: 'column',
9-
alignItems: 'center',
10-
justifyContent: 'center',
11-
gap: 8,
127
color: '#475569',
138
background:
149
'radial-gradient(circle at top, rgba(59, 130, 246, 0.12), rgba(148, 163, 184, 0.12) 45%, rgba(248, 250, 252, 1) 100%)',
@@ -24,10 +19,10 @@ export default function CustomFallbackDemo() {
2419
src="../avatar/not-exists.png"
2520
alt="Unavailable landscape"
2621
fallback={
27-
<div style={fallbackStyle}>
22+
<Flex vertical align="center" justify="center" gap={8} style={fallbackStyle}>
2823
<strong style={{ fontSize: 16 }}>Image unavailable</strong>
2924
<span>The asset could not be loaded.</span>
30-
</div>
25+
</Flex>
3126
}
3227
/>
3328
);

packages/react/src/image/demo/ImageStyle.tsx

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { Image, Radio, Select, Switch } from '@tiny-design/react';
2+
import { Flex, Image, Radio, Select, Switch } from '@tiny-design/react';
33

44
const OBJECT_FIT_OPTIONS = ['cover', 'contain', 'fill', 'none', 'scale-down'] as const;
55

@@ -87,13 +87,12 @@ export default function ImageStyleDemo() {
8787
const previewHeight = round ? 160 : 120;
8888

8989
return (
90-
<div style={{ display: 'grid', gap: 18 }}>
91-
<div
90+
<Flex vertical gap={18}>
91+
<Flex
92+
wrap="wrap"
93+
align="center"
94+
gap={16}
9295
style={{
93-
display: 'flex',
94-
flexWrap: 'wrap',
95-
alignItems: 'center',
96-
gap: 16,
9796
padding: 16,
9897
border: '1px solid #e2e8f0',
9998
borderRadius: 12,
@@ -116,16 +115,16 @@ export default function ImageStyleDemo() {
116115
<Select value={objectPosition} onChange={(value) => setObjectPosition(String(value))} options={focusOptions} />
117116
</div>
118117

119-
<div style={{ display: 'grid', gap: 8 }}>
118+
<Flex vertical gap={8}>
120119
<div style={{ fontSize: 12, color: '#64748b', fontWeight: 600 }}>Round preview</div>
121120
<Switch checked={round} onChange={setRound} />
122-
</div>
123-
</div>
121+
</Flex>
122+
</Flex>
124123

125-
<div style={{ display: 'grid', gap: 8 }}>
124+
<Flex vertical gap={8}>
126125
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Interactive comparison</div>
127-
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
128-
<div style={{ display: 'grid', gap: 8 }}>
126+
<Flex gap={16} wrap="wrap" align="flex-start">
127+
<Flex vertical gap={8}>
129128
<div style={{ fontSize: 12, color: '#64748b' }}>Tall graphic in a wide frame</div>
130129
<Image
131130
width={previewWidth}
@@ -141,9 +140,9 @@ export default function ImageStyleDemo() {
141140
Best for comparing <code>cover</code>, <code>contain</code>, <code>none</code>, and
142141
<code> scale-down</code>. Changing the focal point moves the visible band.
143142
</div>
144-
</div>
143+
</Flex>
145144

146-
<div style={{ display: 'grid', gap: 8 }}>
145+
<Flex vertical gap={8}>
147146
<div style={{ fontSize: 12, color: '#64748b' }}>Square graphic in a wide frame</div>
148147
<Image
149148
width={previewWidth}
@@ -158,9 +157,9 @@ export default function ImageStyleDemo() {
158157
Best for spotting <code>fill</code>: <code>contain</code> keeps a centered square, while
159158
<code> fill</code> stretches the circle and square across the whole frame.
160159
</div>
161-
</div>
160+
</Flex>
162161

163-
<div style={{ display: 'grid', gap: 8 }}>
162+
<Flex vertical gap={8}>
164163
<div style={{ fontSize: 12, color: '#64748b' }}>Small asset in a large frame</div>
165164
<Image
166165
width={previewWidth}
@@ -175,13 +174,13 @@ export default function ImageStyleDemo() {
175174
Useful for seeing why <code>scale-down</code> often matches <code>none</code> when the source is
176175
already smaller than the frame.
177176
</div>
178-
</div>
179-
</div>
177+
</Flex>
178+
</Flex>
180179
<div style={{ color: '#64748b', fontSize: 13, lineHeight: 1.5 }}>
181180
Use the tall preview for crop behavior, the square preview for stretch behavior, and the small preview for
182181
<code> none</code> versus <code>scale-down</code>.
183182
</div>
184-
</div>
185-
</div>
183+
</Flex>
184+
</Flex>
186185
);
187186
}

packages/react/src/image/demo/Lazy.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ export default function LazyDemo() {
1212
<div style={{ height: 100 }} />
1313
</div>
1414
);
15-
}
15+
}
Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,43 @@
11
import React from 'react';
2-
import { Image } from '@tiny-design/react';
3-
4-
const shimmerStyle: React.CSSProperties = {
5-
width: '100%',
6-
height: '100%',
7-
background:
8-
'linear-gradient(90deg, rgba(15, 23, 42, 0.08) 25%, rgba(15, 23, 42, 0.16) 37%, rgba(15, 23, 42, 0.08) 63%)',
9-
backgroundSize: '400% 100%',
10-
animation: 'ty-image-demo-shimmer 1.4s ease infinite',
11-
};
2+
import { Button, Flex, Image, Skeleton } from '@tiny-design/react';
123

134
export default function PlaceholderDemo() {
5+
const [requestId, setRequestId] = React.useState(0);
6+
const [src, setSrc] = React.useState<string>();
7+
const [isReloading, setIsReloading] = React.useState(true);
8+
9+
React.useEffect(() => {
10+
setIsReloading(true);
11+
setSrc(undefined);
12+
13+
const timer = window.setTimeout(() => {
14+
setSrc(
15+
`https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=80&reload=${requestId}`
16+
);
17+
setIsReloading(false);
18+
}, 1400);
19+
20+
return () => {
21+
window.clearTimeout(timer);
22+
};
23+
}, [requestId]);
24+
1425
return (
15-
<div>
16-
<style>
17-
{`
18-
@keyframes ty-image-demo-shimmer {
19-
0% { background-position: 100% 50%; }
20-
100% { background-position: 0 50%; }
21-
}
22-
`}
23-
</style>
26+
<Flex vertical gap={12}>
27+
<Flex justify="space-between" align="center" gap={12}>
28+
<div style={{ fontSize: 12, color: '#64748b' }}>Replay the loading state to inspect the custom placeholder.</div>
29+
<Button size="sm" onClick={() => setRequestId((id) => id + 1)}>
30+
{isReloading ? 'Loading...' : 'Reload'}
31+
</Button>
32+
</Flex>
2433
<Image
34+
key={requestId}
2535
width={320}
2636
height={180}
27-
src="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=80"
37+
src={src}
2838
alt="Mountain lake"
29-
placeholder={<span style={shimmerStyle} />}
39+
placeholder={<Skeleton active style={{ width: '100%', height: '100%' }} />}
3040
/>
31-
</div>
41+
</Flex>
3242
);
3343
}
Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import React from 'react';
2-
import { Image } from '@tiny-design/react';
2+
import { Flex, Image, Slider } from '@tiny-design/react';
33

44
export default function SizeDemo() {
5-
return <Image width={250} height={150} src="https://cdn.pixabay.com/photo/2019/12/21/18/31/discus-fish-4711042__340.jpg" />;
6-
}
5+
const [width, setWidth] = React.useState(250);
6+
const [height, setHeight] = React.useState(150);
7+
8+
return (
9+
<Flex vertical gap={16}>
10+
<Image
11+
width={width}
12+
height={height}
13+
src="https://cdn.pixabay.com/photo/2019/12/21/18/31/discus-fish-4711042__340.jpg"
14+
alt="Discus fish"
15+
/>
16+
17+
<Flex vertical gap={12}>
18+
<div style={{ fontSize: 12, color: '#64748b' }}>Width: {width}px</div>
19+
<Slider min={160} max={360} value={width} onChange={(value) => setWidth(Number(value))} />
20+
21+
<div style={{ fontSize: 12, color: '#64748b' }}>Height: {height}px</div>
22+
<Slider min={100} max={260} value={height} onChange={(value) => setHeight(Number(value))} />
23+
</Flex>
24+
</Flex>
25+
);
26+
}

0 commit comments

Comments
 (0)