Skip to content

Commit 179d46e

Browse files
committed
refactor: image component
1 parent 1293a33 commit 179d46e

12 files changed

Lines changed: 816 additions & 193 deletions

File tree

apps/docs/vite.config.ts

Lines changed: 139 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import { readFileSync } from 'fs';
88

99
const reactPkg = path.resolve(__dirname, '../../packages/react/package.json');
1010
const reactDir = path.resolve(__dirname, '../../packages/react');
11+
const reactSrc = path.join(reactDir, 'src');
1112
const reactEs = path.join(reactDir, 'es/index.js');
1213
const iconsDir = path.resolve(__dirname, '../../packages/icons');
14+
const iconsSrc = path.join(iconsDir, 'src');
1315
const iconsEs = path.join(iconsDir, 'es/index.js');
1416
const chartsDir = path.resolve(__dirname, '../../packages/charts');
17+
const chartsSrc = path.join(chartsDir, 'src');
1518
const chartsEs = path.join(chartsDir, 'es/index.js');
1619
const tokensDir = path.resolve(__dirname, '../../packages/tokens');
1720

@@ -29,119 +32,147 @@ function sanitizeChunkName(value: string): string {
2932
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
3033
}
3134

32-
export default defineConfig({
33-
base: process.env.VITE_BASE || '/',
34-
define: {
35-
__TINY_VERSION__: JSON.stringify(tinyVersion),
36-
},
37-
plugins: [
38-
{ enforce: 'pre', ...mdx({
39-
mdxExtensions: ['.mdx', '.md'],
40-
mdExtensions: [],
41-
providerImportSource: '@mdx-js/react',
42-
remarkPlugins: [remarkGfm],
43-
rehypePlugins: [rehypeMdxCodeProps],
44-
}) },
45-
react({ include: /\.(jsx|tsx|md|mdx)$/ }),
46-
],
47-
resolve: {
48-
alias: [
49-
{ find: /^@tiny-design\/react$/, replacement: reactEs },
50-
{ find: /^@tiny-design\/react\/(.*)$/, replacement: path.join(reactDir, 'es/$1') },
51-
{ find: /^@tiny-design\/icons$/, replacement: iconsEs },
52-
{ find: /^@tiny-design\/icons\/(.*)$/, replacement: path.join(iconsDir, 'es/$1') },
53-
{ find: /^@tiny-design\/charts$/, replacement: chartsEs },
54-
{ find: /^@tiny-design\/charts\/(.*)$/, replacement: path.join(chartsDir, 'es/$1') },
55-
{ find: '@tiny-design/tokens/registry-runtime', replacement: path.join(tokensDir, 'runtime/registry.mjs') },
56-
{ find: '@mdx-js/react', replacement: path.resolve(__dirname, 'node_modules/@mdx-js/react') },
35+
function createWorkspaceAliases(mode: 'src' | 'es') {
36+
const useSource = mode === 'src';
37+
38+
return [
39+
{
40+
find: /^@tiny-design\/react$/,
41+
replacement: useSource ? reactSrc : reactEs,
42+
},
43+
{
44+
find: /^@tiny-design\/react\/(.*)$/,
45+
replacement: path.join(reactDir, `${useSource ? 'src' : 'es'}/$1`),
46+
},
47+
{
48+
find: /^@tiny-design\/icons$/,
49+
replacement: useSource ? iconsSrc : iconsEs,
50+
},
51+
{
52+
find: /^@tiny-design\/icons\/(.*)$/,
53+
replacement: path.join(iconsDir, `${useSource ? 'src' : 'es'}/$1`),
54+
},
55+
{
56+
find: /^@tiny-design\/charts$/,
57+
replacement: useSource ? chartsSrc : chartsEs,
58+
},
59+
{
60+
find: /^@tiny-design\/charts\/(.*)$/,
61+
replacement: path.join(chartsDir, `${useSource ? 'src' : 'es'}/$1`),
62+
},
63+
{ find: '@tiny-design/tokens/registry-runtime', replacement: path.join(tokensDir, 'runtime/registry.mjs') },
64+
{ find: '@mdx-js/react', replacement: path.resolve(__dirname, 'node_modules/@mdx-js/react') },
65+
];
66+
}
67+
68+
export default defineConfig(({ command }) => {
69+
const aliasMode = command === 'serve' ? 'src' : 'es';
70+
71+
return {
72+
base: process.env.VITE_BASE || '/',
73+
define: {
74+
__TINY_VERSION__: JSON.stringify(tinyVersion),
75+
},
76+
plugins: [
77+
{ enforce: 'pre', ...mdx({
78+
mdxExtensions: ['.mdx', '.md'],
79+
mdExtensions: [],
80+
providerImportSource: '@mdx-js/react',
81+
remarkPlugins: [remarkGfm],
82+
rehypePlugins: [rehypeMdxCodeProps],
83+
}) },
84+
react({ include: /\.(jsx|tsx|md|mdx)$/ }),
5785
],
58-
dedupe: ['react', 'react-dom'],
59-
},
60-
server: {
61-
port: 3000,
62-
open: true,
63-
fs: {
64-
allow: ['../..'],
86+
resolve: {
87+
alias: createWorkspaceAliases(aliasMode),
88+
dedupe: ['react', 'react-dom'],
6589
},
66-
},
67-
build: {
68-
outDir: 'build',
69-
chunkSizeWarningLimit: 750,
70-
rollupOptions: {
71-
output: {
72-
manualChunks(id) {
73-
if (id.includes('/packages/react/es/')) {
74-
return 'tiny-react';
75-
}
76-
77-
if (id.includes('/packages/icons/es/')) {
78-
return 'tiny-icons';
79-
}
80-
81-
if (id.includes('/packages/charts/es/')) {
82-
return 'tiny-charts';
83-
}
84-
85-
if (id.includes('/apps/docs/src/containers/home/')) {
86-
return 'docs-home';
87-
}
88-
89-
if (!id.includes('node_modules')) {
90-
return undefined;
91-
}
92-
93-
const packageName = getPackageName(id);
94-
95-
if (packageName && ['react', 'react-dom', 'scheduler'].includes(packageName)) {
96-
return 'vendor-react';
97-
}
98-
99-
if (packageName && ['@babel/runtime', 'dom-helpers'].includes(packageName)) {
100-
return 'vendor-react';
101-
}
102-
103-
if (
104-
packageName &&
105-
(
106-
packageName.startsWith('@mdx-js') ||
107-
packageName.startsWith('remark-') ||
108-
packageName.startsWith('rehype-') ||
109-
[
110-
'unified',
111-
'micromark',
112-
'mdast-util-from-markdown',
113-
'mdast-util-mdx',
114-
'mdast-util-mdx-expression',
115-
'mdast-util-mdx-jsx',
116-
'mdast-util-mdxjs-esm',
117-
'mdast-util-to-hast',
118-
'hast-util-to-jsx-runtime',
119-
'hast-util-from-html',
120-
'vfile',
121-
].includes(packageName)
122-
)
123-
) {
124-
return 'vendor-mdx';
125-
}
126-
127-
if (packageName && ['prism-react-renderer', 'react-runner'].includes(packageName)) {
128-
return 'vendor-code';
129-
}
130-
131-
if (packageName === 'recharts' || packageName === 'victory-vendor') {
132-
return 'vendor-charts';
133-
}
134-
135-
return packageName ? `vendor-${sanitizeChunkName(packageName)}` : 'vendor';
90+
server: {
91+
port: 3000,
92+
open: true,
93+
fs: {
94+
allow: ['../..'],
95+
},
96+
},
97+
build: {
98+
outDir: 'build',
99+
chunkSizeWarningLimit: 750,
100+
rollupOptions: {
101+
output: {
102+
manualChunks(id) {
103+
if (id.includes('/packages/react/es/')) {
104+
return 'tiny-react';
105+
}
106+
107+
if (id.includes('/packages/icons/es/')) {
108+
return 'tiny-icons';
109+
}
110+
111+
if (id.includes('/packages/charts/es/')) {
112+
return 'tiny-charts';
113+
}
114+
115+
if (id.includes('/apps/docs/src/containers/home/')) {
116+
return 'docs-home';
117+
}
118+
119+
if (!id.includes('node_modules')) {
120+
return undefined;
121+
}
122+
123+
const packageName = getPackageName(id);
124+
125+
if (packageName && ['react', 'react-dom', 'scheduler'].includes(packageName)) {
126+
return 'vendor-react';
127+
}
128+
129+
if (packageName && ['@babel/runtime', 'dom-helpers'].includes(packageName)) {
130+
return 'vendor-react';
131+
}
132+
133+
if (
134+
packageName &&
135+
(
136+
packageName.startsWith('@mdx-js') ||
137+
packageName.startsWith('remark-') ||
138+
packageName.startsWith('rehype-') ||
139+
[
140+
'unified',
141+
'micromark',
142+
'mdast-util-from-markdown',
143+
'mdast-util-mdx',
144+
'mdast-util-mdx-expression',
145+
'mdast-util-mdx-jsx',
146+
'mdast-util-mdxjs-esm',
147+
'mdast-util-to-hast',
148+
'hast-util-to-jsx-runtime',
149+
'hast-util-from-html',
150+
'vfile',
151+
].includes(packageName)
152+
)
153+
) {
154+
return 'vendor-mdx';
155+
}
156+
157+
if (packageName && ['prism-react-renderer', 'react-runner'].includes(packageName)) {
158+
return 'vendor-code';
159+
}
160+
161+
if (packageName === 'recharts' || packageName === 'victory-vendor') {
162+
return 'vendor-charts';
163+
}
164+
165+
return packageName ? `vendor-${sanitizeChunkName(packageName)}` : 'vendor';
166+
},
136167
},
137168
},
138169
},
139-
},
140-
css: {
141-
preprocessorOptions: {
142-
scss: {
143-
api: 'modern',
170+
css: {
171+
preprocessorOptions: {
172+
scss: {
173+
api: 'modern',
174+
},
144175
},
145176
},
146-
},
177+
};
147178
});

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22

33
exports[`<Image /> should match the snapshot 1`] = `
44
<DocumentFragment>
5-
<img
6-
alt="image"
7-
class="ty-image"
8-
src="test.jpg"
9-
style="object-fit: cover;"
10-
/>
5+
<span
6+
class="ty-image ty-image_loading"
7+
data-status="loading"
8+
>
9+
<img
10+
alt=""
11+
class="ty-image__img"
12+
src="test.jpg"
13+
style="object-fit: cover;"
14+
/>
15+
</span>
1116
</DocumentFragment>
1217
`;
Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import React from 'react';
2-
import { render } from '@testing-library/react';
2+
import { fireEvent, render, screen } from '@testing-library/react';
33
import Image from '../index';
44

55
describe('<Image />', () => {
6+
const originalIntersectionObserver = window.IntersectionObserver;
7+
8+
afterEach(() => {
9+
window.IntersectionObserver = originalIntersectionObserver;
10+
});
11+
612
it('should match the snapshot', () => {
713
const { asFragment } = render(<Image src="test.jpg" />);
814
expect(asFragment()).toMatchSnapshot();
@@ -13,18 +19,71 @@ describe('<Image />', () => {
1319
expect(container.querySelector('img')).toBeTruthy();
1420
});
1521

16-
it('should render with round style', () => {
22+
it('should render with round style on container', () => {
1723
const { container } = render(<Image src="test.jpg" round />);
1824
expect(container.firstChild).toHaveClass('ty-image_round');
1925
});
2026

21-
it('should render with alt text', () => {
22-
const { container } = render(<Image src="test.jpg" alt="photo" />);
23-
expect(container.querySelector('img')?.getAttribute('alt')).toBe('photo');
27+
it('should default alt to empty string', () => {
28+
render(<Image src="test.jpg" />);
29+
expect(screen.getByRole('img')).toHaveAttribute('alt', '');
2430
});
2531

26-
it('should apply object-fit style', () => {
27-
const { container } = render(<Image src="test.jpg" objectFit="contain" />);
28-
expect(container.querySelector('img')).toHaveStyle({ objectFit: 'contain' });
32+
it('should apply object-fit style to image layer', () => {
33+
render(<Image src="test.jpg" objectFit="contain" />);
34+
expect(screen.getByRole('img')).toHaveStyle({ objectFit: 'contain' });
35+
});
36+
37+
it('should switch to fallback image when src fails', () => {
38+
render(<Image src="broken.jpg" fallback="fallback.jpg" alt="cover" />);
39+
40+
const image = screen.getByRole('img', { name: 'cover' });
41+
fireEvent.error(image);
42+
43+
expect(screen.getByRole('img', { name: 'cover' })).toHaveAttribute('src', 'fallback.jpg');
44+
});
45+
46+
it('should render fallback node when image and fallback image are unavailable', () => {
47+
render(
48+
<Image
49+
src="broken.jpg"
50+
fallback={<span data-testid="fallback-node">fallback node</span>}
51+
alt="cover"
52+
/>
53+
);
54+
55+
const image = screen.getByRole('img', { name: 'cover' });
56+
fireEvent.error(image);
57+
58+
expect(screen.getByTestId('fallback-node')).toBeInTheDocument();
59+
});
60+
61+
it('should keep lazy image on placeholder before intersecting', () => {
62+
const observe = jest.fn();
63+
const unobserve = jest.fn();
64+
const disconnect = jest.fn();
65+
66+
class MockIntersectionObserver {
67+
observe = observe;
68+
unobserve = unobserve;
69+
disconnect = disconnect;
70+
}
71+
72+
window.IntersectionObserver = MockIntersectionObserver as typeof window.IntersectionObserver;
73+
74+
const { container } = render(<Image lazy src="test.jpg" placeholder="placeholder.jpg" />);
75+
76+
expect(container.firstChild).toHaveAttribute('data-status', 'idle');
77+
expect(container.querySelector('.ty-image__img')).toBeNull();
78+
expect(observe).toHaveBeenCalled();
79+
});
80+
81+
it('should fall back to eager loading when IntersectionObserver is unavailable', () => {
82+
// @ts-expect-error test fallback behavior when observer is not supported
83+
window.IntersectionObserver = undefined;
84+
85+
render(<Image lazy src="test.jpg" placeholder="placeholder.jpg" alt="cover" />);
86+
87+
expect(screen.getByRole('img', { name: 'cover' })).toHaveAttribute('src', 'test.jpg');
2988
});
3089
});

0 commit comments

Comments
 (0)