From 179d46ed1e5c5af549bac0e7ca56857f3e515e28 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 18 Apr 2026 11:52:00 +1000 Subject: [PATCH 1/2] refactor: image component --- apps/docs/vite.config.ts | 247 ++++++++++-------- .../__snapshots__/image.test.tsx.snap | 17 +- .../react/src/image/__tests__/image.test.tsx | 75 +++++- .../react/src/image/demo/CustomFallback.tsx | 34 +++ packages/react/src/image/demo/Fallback.tsx | 4 +- packages/react/src/image/demo/ImageStyle.tsx | 187 +++++++++++++ packages/react/src/image/demo/Placeholder.tsx | 33 +++ packages/react/src/image/image.tsx | 202 ++++++++++++-- packages/react/src/image/index.md | 68 +++-- packages/react/src/image/index.zh_CN.md | 68 +++-- packages/react/src/image/style/index.scss | 62 +++++ packages/react/src/image/types.ts | 12 +- 12 files changed, 816 insertions(+), 193 deletions(-) create mode 100644 packages/react/src/image/demo/CustomFallback.tsx create mode 100644 packages/react/src/image/demo/ImageStyle.tsx create mode 100644 packages/react/src/image/demo/Placeholder.tsx mode change 100755 => 100644 packages/react/src/image/image.tsx diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index ec5fd0fcb..3ac9c70e4 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -8,10 +8,13 @@ import { readFileSync } from 'fs'; const reactPkg = path.resolve(__dirname, '../../packages/react/package.json'); const reactDir = path.resolve(__dirname, '../../packages/react'); +const reactSrc = path.join(reactDir, 'src'); const reactEs = path.join(reactDir, 'es/index.js'); const iconsDir = path.resolve(__dirname, '../../packages/icons'); +const iconsSrc = path.join(iconsDir, 'src'); const iconsEs = path.join(iconsDir, 'es/index.js'); const chartsDir = path.resolve(__dirname, '../../packages/charts'); +const chartsSrc = path.join(chartsDir, 'src'); const chartsEs = path.join(chartsDir, 'es/index.js'); const tokensDir = path.resolve(__dirname, '../../packages/tokens'); @@ -29,119 +32,147 @@ function sanitizeChunkName(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, '-'); } -export default defineConfig({ - base: process.env.VITE_BASE || '/', - define: { - __TINY_VERSION__: JSON.stringify(tinyVersion), - }, - plugins: [ - { enforce: 'pre', ...mdx({ - mdxExtensions: ['.mdx', '.md'], - mdExtensions: [], - providerImportSource: '@mdx-js/react', - remarkPlugins: [remarkGfm], - rehypePlugins: [rehypeMdxCodeProps], - }) }, - react({ include: /\.(jsx|tsx|md|mdx)$/ }), - ], - resolve: { - alias: [ - { find: /^@tiny-design\/react$/, replacement: reactEs }, - { find: /^@tiny-design\/react\/(.*)$/, replacement: path.join(reactDir, 'es/$1') }, - { find: /^@tiny-design\/icons$/, replacement: iconsEs }, - { find: /^@tiny-design\/icons\/(.*)$/, replacement: path.join(iconsDir, 'es/$1') }, - { find: /^@tiny-design\/charts$/, replacement: chartsEs }, - { find: /^@tiny-design\/charts\/(.*)$/, replacement: path.join(chartsDir, 'es/$1') }, - { find: '@tiny-design/tokens/registry-runtime', replacement: path.join(tokensDir, 'runtime/registry.mjs') }, - { find: '@mdx-js/react', replacement: path.resolve(__dirname, 'node_modules/@mdx-js/react') }, +function createWorkspaceAliases(mode: 'src' | 'es') { + const useSource = mode === 'src'; + + return [ + { + find: /^@tiny-design\/react$/, + replacement: useSource ? reactSrc : reactEs, + }, + { + find: /^@tiny-design\/react\/(.*)$/, + replacement: path.join(reactDir, `${useSource ? 'src' : 'es'}/$1`), + }, + { + find: /^@tiny-design\/icons$/, + replacement: useSource ? iconsSrc : iconsEs, + }, + { + find: /^@tiny-design\/icons\/(.*)$/, + replacement: path.join(iconsDir, `${useSource ? 'src' : 'es'}/$1`), + }, + { + find: /^@tiny-design\/charts$/, + replacement: useSource ? chartsSrc : chartsEs, + }, + { + find: /^@tiny-design\/charts\/(.*)$/, + replacement: path.join(chartsDir, `${useSource ? 'src' : 'es'}/$1`), + }, + { find: '@tiny-design/tokens/registry-runtime', replacement: path.join(tokensDir, 'runtime/registry.mjs') }, + { find: '@mdx-js/react', replacement: path.resolve(__dirname, 'node_modules/@mdx-js/react') }, + ]; +} + +export default defineConfig(({ command }) => { + const aliasMode = command === 'serve' ? 'src' : 'es'; + + return { + base: process.env.VITE_BASE || '/', + define: { + __TINY_VERSION__: JSON.stringify(tinyVersion), + }, + plugins: [ + { enforce: 'pre', ...mdx({ + mdxExtensions: ['.mdx', '.md'], + mdExtensions: [], + providerImportSource: '@mdx-js/react', + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeMdxCodeProps], + }) }, + react({ include: /\.(jsx|tsx|md|mdx)$/ }), ], - dedupe: ['react', 'react-dom'], - }, - server: { - port: 3000, - open: true, - fs: { - allow: ['../..'], + resolve: { + alias: createWorkspaceAliases(aliasMode), + dedupe: ['react', 'react-dom'], }, - }, - build: { - outDir: 'build', - chunkSizeWarningLimit: 750, - rollupOptions: { - output: { - manualChunks(id) { - if (id.includes('/packages/react/es/')) { - return 'tiny-react'; - } - - if (id.includes('/packages/icons/es/')) { - return 'tiny-icons'; - } - - if (id.includes('/packages/charts/es/')) { - return 'tiny-charts'; - } - - if (id.includes('/apps/docs/src/containers/home/')) { - return 'docs-home'; - } - - if (!id.includes('node_modules')) { - return undefined; - } - - const packageName = getPackageName(id); - - if (packageName && ['react', 'react-dom', 'scheduler'].includes(packageName)) { - return 'vendor-react'; - } - - if (packageName && ['@babel/runtime', 'dom-helpers'].includes(packageName)) { - return 'vendor-react'; - } - - if ( - packageName && - ( - packageName.startsWith('@mdx-js') || - packageName.startsWith('remark-') || - packageName.startsWith('rehype-') || - [ - 'unified', - 'micromark', - 'mdast-util-from-markdown', - 'mdast-util-mdx', - 'mdast-util-mdx-expression', - 'mdast-util-mdx-jsx', - 'mdast-util-mdxjs-esm', - 'mdast-util-to-hast', - 'hast-util-to-jsx-runtime', - 'hast-util-from-html', - 'vfile', - ].includes(packageName) - ) - ) { - return 'vendor-mdx'; - } - - if (packageName && ['prism-react-renderer', 'react-runner'].includes(packageName)) { - return 'vendor-code'; - } - - if (packageName === 'recharts' || packageName === 'victory-vendor') { - return 'vendor-charts'; - } - - return packageName ? `vendor-${sanitizeChunkName(packageName)}` : 'vendor'; + server: { + port: 3000, + open: true, + fs: { + allow: ['../..'], + }, + }, + build: { + outDir: 'build', + chunkSizeWarningLimit: 750, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('/packages/react/es/')) { + return 'tiny-react'; + } + + if (id.includes('/packages/icons/es/')) { + return 'tiny-icons'; + } + + if (id.includes('/packages/charts/es/')) { + return 'tiny-charts'; + } + + if (id.includes('/apps/docs/src/containers/home/')) { + return 'docs-home'; + } + + if (!id.includes('node_modules')) { + return undefined; + } + + const packageName = getPackageName(id); + + if (packageName && ['react', 'react-dom', 'scheduler'].includes(packageName)) { + return 'vendor-react'; + } + + if (packageName && ['@babel/runtime', 'dom-helpers'].includes(packageName)) { + return 'vendor-react'; + } + + if ( + packageName && + ( + packageName.startsWith('@mdx-js') || + packageName.startsWith('remark-') || + packageName.startsWith('rehype-') || + [ + 'unified', + 'micromark', + 'mdast-util-from-markdown', + 'mdast-util-mdx', + 'mdast-util-mdx-expression', + 'mdast-util-mdx-jsx', + 'mdast-util-mdxjs-esm', + 'mdast-util-to-hast', + 'hast-util-to-jsx-runtime', + 'hast-util-from-html', + 'vfile', + ].includes(packageName) + ) + ) { + return 'vendor-mdx'; + } + + if (packageName && ['prism-react-renderer', 'react-runner'].includes(packageName)) { + return 'vendor-code'; + } + + if (packageName === 'recharts' || packageName === 'victory-vendor') { + return 'vendor-charts'; + } + + return packageName ? `vendor-${sanitizeChunkName(packageName)}` : 'vendor'; + }, }, }, }, - }, - css: { - preprocessorOptions: { - scss: { - api: 'modern', + css: { + preprocessorOptions: { + scss: { + api: 'modern', + }, }, }, - }, + }; }); diff --git a/packages/react/src/image/__tests__/__snapshots__/image.test.tsx.snap b/packages/react/src/image/__tests__/__snapshots__/image.test.tsx.snap index 60cc5a649..c11e27268 100644 --- a/packages/react/src/image/__tests__/__snapshots__/image.test.tsx.snap +++ b/packages/react/src/image/__tests__/__snapshots__/image.test.tsx.snap @@ -2,11 +2,16 @@ exports[` should match the snapshot 1`] = ` - image + + + `; diff --git a/packages/react/src/image/__tests__/image.test.tsx b/packages/react/src/image/__tests__/image.test.tsx index 188cff462..fce988eb0 100644 --- a/packages/react/src/image/__tests__/image.test.tsx +++ b/packages/react/src/image/__tests__/image.test.tsx @@ -1,8 +1,14 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import Image from '../index'; describe('', () => { + const originalIntersectionObserver = window.IntersectionObserver; + + afterEach(() => { + window.IntersectionObserver = originalIntersectionObserver; + }); + it('should match the snapshot', () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); @@ -13,18 +19,71 @@ describe('', () => { expect(container.querySelector('img')).toBeTruthy(); }); - it('should render with round style', () => { + it('should render with round style on container', () => { const { container } = render(); expect(container.firstChild).toHaveClass('ty-image_round'); }); - it('should render with alt text', () => { - const { container } = render(photo); - expect(container.querySelector('img')?.getAttribute('alt')).toBe('photo'); + it('should default alt to empty string', () => { + render(); + expect(screen.getByRole('img')).toHaveAttribute('alt', ''); }); - it('should apply object-fit style', () => { - const { container } = render(); - expect(container.querySelector('img')).toHaveStyle({ objectFit: 'contain' }); + it('should apply object-fit style to image layer', () => { + render(); + expect(screen.getByRole('img')).toHaveStyle({ objectFit: 'contain' }); + }); + + it('should switch to fallback image when src fails', () => { + render(cover); + + const image = screen.getByRole('img', { name: 'cover' }); + fireEvent.error(image); + + expect(screen.getByRole('img', { name: 'cover' })).toHaveAttribute('src', 'fallback.jpg'); + }); + + it('should render fallback node when image and fallback image are unavailable', () => { + render( + fallback node} + alt="cover" + /> + ); + + const image = screen.getByRole('img', { name: 'cover' }); + fireEvent.error(image); + + expect(screen.getByTestId('fallback-node')).toBeInTheDocument(); + }); + + it('should keep lazy image on placeholder before intersecting', () => { + const observe = jest.fn(); + const unobserve = jest.fn(); + const disconnect = jest.fn(); + + class MockIntersectionObserver { + observe = observe; + unobserve = unobserve; + disconnect = disconnect; + } + + window.IntersectionObserver = MockIntersectionObserver as typeof window.IntersectionObserver; + + const { container } = render(); + + expect(container.firstChild).toHaveAttribute('data-status', 'idle'); + expect(container.querySelector('.ty-image__img')).toBeNull(); + expect(observe).toHaveBeenCalled(); + }); + + it('should fall back to eager loading when IntersectionObserver is unavailable', () => { + // @ts-expect-error test fallback behavior when observer is not supported + window.IntersectionObserver = undefined; + + render(cover); + + expect(screen.getByRole('img', { name: 'cover' })).toHaveAttribute('src', 'test.jpg'); }); }); diff --git a/packages/react/src/image/demo/CustomFallback.tsx b/packages/react/src/image/demo/CustomFallback.tsx new file mode 100644 index 000000000..21492bb41 --- /dev/null +++ b/packages/react/src/image/demo/CustomFallback.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Image } from '@tiny-design/react'; + +const fallbackStyle: React.CSSProperties = { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + color: '#475569', + background: + 'radial-gradient(circle at top, rgba(59, 130, 246, 0.12), rgba(148, 163, 184, 0.12) 45%, rgba(248, 250, 252, 1) 100%)', + fontSize: 14, + textAlign: 'center', +}; + +export default function CustomFallbackDemo() { + return ( + Unavailable landscape + Image unavailable + The asset could not be loaded. + + } + /> + ); +} diff --git a/packages/react/src/image/demo/Fallback.tsx b/packages/react/src/image/demo/Fallback.tsx index 6aac5c731..98137758c 100644 --- a/packages/react/src/image/demo/Fallback.tsx +++ b/packages/react/src/image/demo/Fallback.tsx @@ -2,5 +2,5 @@ import React from 'react'; import { Image } from '@tiny-design/react'; export default function FallbackDemo() { - return ; -} \ No newline at end of file + return ; +} diff --git a/packages/react/src/image/demo/ImageStyle.tsx b/packages/react/src/image/demo/ImageStyle.tsx new file mode 100644 index 000000000..60ed57986 --- /dev/null +++ b/packages/react/src/image/demo/ImageStyle.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { Image, Radio, Select, Switch } from '@tiny-design/react'; + +const OBJECT_FIT_OPTIONS = ['cover', 'contain', 'fill', 'none', 'scale-down'] as const; + +function createSvgDataUrl(svg: string): string { + return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}`; +} + +function createTallPoster(): string { + return createSvgDataUrl(` + + + + + + + + + + TOP + CENTER + BOTTOM + + `); +} + +function createStretchChart(): string { + return createSvgDataUrl(` + + + + + + + + + + CIRCLE + SQUARE + + `); +} + +function createSmallBadge(): string { + return createSvgDataUrl(` + + + + + + 96×64 + + `); +} + +const focusOptions = [ + { label: 'Center', value: 'center center' }, + { label: 'Top section', value: 'center 15%' }, + { label: 'Bottom section', value: 'center 85%' }, +]; + +const tallSrc = createTallPoster(); +const chartSrc = createStretchChart(); +const smallSrc = createSmallBadge(); + +const checkerboardStyle: React.CSSProperties = { + boxShadow: '0 8px 24px rgba(15, 23, 42, 0.12)', + backgroundColor: '#e2e8f0', + backgroundImage: + 'linear-gradient(45deg, rgba(148, 163, 184, 0.18) 25%, transparent 25%), linear-gradient(-45deg, rgba(148, 163, 184, 0.18) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(148, 163, 184, 0.18) 75%), linear-gradient(-45deg, transparent 75%, rgba(148, 163, 184, 0.18) 75%)', + backgroundSize: '18px 18px', + backgroundPosition: '0 0, 0 9px, 9px -9px, -9px 0', +}; + +const captionStyle: React.CSSProperties = { + fontSize: 12, + color: '#64748b', + lineHeight: 1.5, +}; + +export default function ImageStyleDemo() { + const [objectFit, setObjectFit] = React.useState<(typeof OBJECT_FIT_OPTIONS)[number]>('cover'); + const [objectPosition, setObjectPosition] = React.useState('center center'); + const [round, setRound] = React.useState(false); + const previewWidth = round ? 160 : 240; + const previewHeight = round ? 160 : 120; + + return ( +
+
+
+
objectFit
+ setObjectFit(value as (typeof OBJECT_FIT_OPTIONS)[number])}> + {OBJECT_FIT_OPTIONS.map((option) => ( + + {option} + + ))} + +
+ +
+
Focal point
+