From 7712c95cd435f3cdab378fdb264c3e30bddddecd Mon Sep 17 00:00:00 2001 From: minjee kim Date: Tue, 28 Apr 2026 17:18:41 +0900 Subject: [PATCH 1/4] feat(image): add image component --- packages/image/global.d.ts | 1 + packages/image/package.json | 57 +++++++ packages/image/src/Image.stories.tsx | 176 +++++++++++++++++++++ packages/image/src/Image.test.tsx | 99 ++++++++++++ packages/image/src/Image.tsx | 72 +++++++++ packages/image/src/hooks/useImageStatus.ts | 53 +++++++ packages/image/src/index.ts | 2 + packages/image/tsconfig.json | 3 + packages/image/tsup.config.ts | 8 + packages/image/vitest.config.ts | 12 ++ packages/image/vitest.setup.ts | 1 + 11 files changed, 484 insertions(+) create mode 100644 packages/image/global.d.ts create mode 100644 packages/image/package.json create mode 100644 packages/image/src/Image.stories.tsx create mode 100644 packages/image/src/Image.test.tsx create mode 100644 packages/image/src/Image.tsx create mode 100644 packages/image/src/hooks/useImageStatus.ts create mode 100644 packages/image/src/index.ts create mode 100644 packages/image/tsconfig.json create mode 100644 packages/image/tsup.config.ts create mode 100644 packages/image/vitest.config.ts create mode 100644 packages/image/vitest.setup.ts diff --git a/packages/image/global.d.ts b/packages/image/global.d.ts new file mode 100644 index 00000000..60260a3a --- /dev/null +++ b/packages/image/global.d.ts @@ -0,0 +1 @@ +declare module '*.module.css'; diff --git a/packages/image/package.json b/packages/image/package.json new file mode 100644 index 00000000..40b2bdcf --- /dev/null +++ b/packages/image/package.json @@ -0,0 +1,57 @@ +{ + "name": "@sipe-team/image", + "description": "Image for Sipe Design System", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/side" + }, + "type": "module", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "lint": "pnpm exec biome lint", + "test": "vitest", + "typecheck": "tsc", + "prepack": "pnpm run build" + }, + "devDependencies": { + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "catalog:react", + "happy-dom": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">= 18", + "react-dom": ">= 18" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./styles.css": "./dist/index.css" + } + }, + "sideEffects": [ + "**/*.css" + ] +} diff --git a/packages/image/src/Image.stories.tsx b/packages/image/src/Image.stories.tsx new file mode 100644 index 00000000..0c447da9 --- /dev/null +++ b/packages/image/src/Image.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Image } from './Image'; + +const meta: Meta = { + title: 'Components/Image', + component: Image, + parameters: { + backgrounds: { + default: 'light', + }, + }, + args: { + src: 'https://picsum.photos/400/300', + alt: '예시 이미지', + width: 400, + height: 300, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + parameters: { + docs: { + description: { + story: '기본 이미지 렌더링입니다. 기본 크기와 기본 fit 동작을 확인할 수 있습니다.', + }, + }, + }, +}; + +export const Fallbacks: Story = { + parameters: { + docs: { + description: { + story: 'fallbackSrc가 있을 때와 없을 때 동작을 한 번에 비교합니다.', + }, + }, + }, + render: (args) => ( +
+
+ fallback with src + with fallbackSrc +
+
+
+ error without fallback +
+ without fallbackSrc +
+
+ ), + args: { + width: 400, + height: 300, + }, +}; + +export const WithPlaceholder: Story = { + parameters: { + docs: { + description: { + story: '로딩 중 placeholder 확인은 DevTools에서 네트워크를 Slow 3G로 설정 후 새로고침하세요.', + }, + }, + }, + args: { + src: 'https://picsum.photos/400/300', + alt: 'placeholder 예시', + placeholder: ( +
+ 로딩 중... +
+ ), + }, +}; + +export const WithFill: Story = { + parameters: { + docs: { + description: { + story: 'fill 사용 예시입니다. 부모 컨테이너에 `position: relative`, `width: 400`, `height: 300`이 필요합니다.', + }, + }, + }, + render: (args) => ( +
+ +
+ ), + args: { + src: 'https://picsum.photos/400/300', + }, +}; + +export const Fits: Story = { + parameters: { + docs: { + description: { + story: 'fit 옵션(`contain`, `cover`)을 같은 크기 박스에서 비교하는 예시입니다.', + }, + }, + }, + render: (args) => ( +
+
+ fit contain + fit: contain +
+
+ fit cover + fit: cover +
+
+ ), + args: { + src: 'https://picsum.photos/600/200', + width: 300, + height: 300, + }, +}; + +export const ResponsiveWidth: Story = { + parameters: { + docs: { + description: { + story: 'width를 string(100%)으로 전달했을 때 부모 너비를 따라 반응형으로 동작하는 예시입니다.', + }, + }, + }, + render: (args) => ( +
+ +
+ ), + args: { + src: 'https://picsum.photos/400/300', + width: '100%', + height: 300, + }, +}; diff --git a/packages/image/src/Image.test.tsx b/packages/image/src/Image.test.tsx new file mode 100644 index 00000000..4b23d0a8 --- /dev/null +++ b/packages/image/src/Image.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Image } from './Image'; + +describe('Image', () => { + it('renders with required src and alt props', () => { + render(sample image); + + const img = screen.getByRole('img', { name: 'sample image' }); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://picsum.photos/400/300'); + }); + + it('applies width and height styles for number and string values', () => { + render(size test); + + const img = screen.getByRole('img', { name: 'size test' }); + expect(img).toHaveStyle({ + width: '320px', + height: '50%', + }); + }); + + it('switches to fallbackSrc once when image load fails', () => { + render( + fallback test, + ); + + const img = screen.getByRole('img', { name: 'fallback test' }); + fireEvent.error(img); + + expect(img).toHaveAttribute('src', 'https://dummyimage.com/400x300/e5e7eb/111827&text=FALLBACK'); + }); + + it('moves to error state and hides image when fallback is unavailable', () => { + render(error test); + + const img = screen.getByRole('img', { name: 'error test' }); + fireEvent.error(img); + + expect(img).toHaveStyle({ visibility: 'hidden' }); + }); + + it('shows placeholder while loading and hides it after load', () => { + render( + placeholder test + loading... + + } + />, + ); + + const img = screen.getByAltText('placeholder test'); + expect(screen.getByTestId('placeholder')).toBeInTheDocument(); + expect(img).toHaveStyle({ visibility: 'hidden' }); + + fireEvent.load(img); + + expect(screen.queryByTestId('placeholder')).not.toBeInTheDocument(); + expect(img.style.visibility).toBe(''); + }); + + it('applies fill styles when fill is true', () => { + render(fill test); + + const img = screen.getByRole('img', { name: 'fill test' }); + expect(img).toHaveStyle({ + position: 'absolute', + width: '100%', + height: '100%', + }); + }); + + it('uses lazy loading by default', () => { + render(loading attr test); + + const img = screen.getByRole('img', { name: 'loading attr test' }); + expect(img).toHaveAttribute('loading', 'lazy'); + }); + + it('always calls user onError callback', () => { + const onError = vi.fn(); + render(onError test); + + const img = screen.getByRole('img', { name: 'onError test' }); + fireEvent.error(img); + + expect(onError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/image/src/Image.tsx b/packages/image/src/Image.tsx new file mode 100644 index 00000000..5c2f7dc4 --- /dev/null +++ b/packages/image/src/Image.tsx @@ -0,0 +1,72 @@ +import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'; + +import { useImageStatus } from './hooks/useImageStatus'; + +type ImageSize = number | string; +type ImageFit = 'contain' | 'cover' | 'fill'; + +export interface ImageProps + extends Omit, 'src' | 'alt' | 'width' | 'height' | 'onLoad'> { + src: string; + alt: string; + width?: ImageSize; + height?: ImageSize; + fit?: ImageFit; + fill?: boolean; + fallbackSrc?: string; + placeholder?: ReactNode; +} + +export function Image({ + src, + alt, + width, + height, + fit = 'cover', + fill = false, + fallbackSrc, + placeholder, + loading = 'lazy', + onError, + style, + ...props +}: ImageProps) { + const { status, imgSrc, handleLoad, handleError } = useImageStatus({ + src, + ...(fallbackSrc ? { fallbackSrc } : {}), + ...(onError ? { onError } : {}), + }); + + const showPlaceholder = status === 'loading' && placeholder !== undefined; + const isHidden = showPlaceholder || status === 'error'; + + const sizeStyle: CSSProperties = { + width: typeof width === 'number' ? `${width}px` : width, + height: typeof height === 'number' ? `${height}px` : height, + objectFit: fit, + }; + + const fillStyle: CSSProperties = fill + ? { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + } + : {}; + + return ( + <> + {showPlaceholder ? placeholder : null} + {alt} + + ); +} diff --git a/packages/image/src/hooks/useImageStatus.ts b/packages/image/src/hooks/useImageStatus.ts new file mode 100644 index 00000000..ddbc0d32 --- /dev/null +++ b/packages/image/src/hooks/useImageStatus.ts @@ -0,0 +1,53 @@ +import { type SyntheticEvent, useCallback, useState } from 'react'; + +export type ImageStatus = 'loading' | 'normal' | 'fallback' | 'error'; + +interface UseImageStatusParams { + src: string; + fallbackSrc?: string; + onError?: (event: SyntheticEvent) => void; +} + +interface UseImageStatusResult { + status: ImageStatus; + imgSrc: string; + handleLoad: (event: SyntheticEvent) => void; + handleError: (event: SyntheticEvent) => void; +} + +export function useImageStatus({ + src, + fallbackSrc, + onError: onErrorFromProps, +}: UseImageStatusParams): UseImageStatusResult { + const [status, setStatus] = useState('loading'); + const [imgSrc, setImgSrc] = useState(src); + const [hasTriedFallback, setHasTriedFallback] = useState(false); + + const handleLoad = useCallback((_event: SyntheticEvent) => { + setStatus('normal'); + }, []); + + const handleError = useCallback( + (event: SyntheticEvent) => { + onErrorFromProps?.(event); + + if (!hasTriedFallback && fallbackSrc) { + setImgSrc(fallbackSrc); + setHasTriedFallback(true); + setStatus('loading'); + return; + } + + setStatus('error'); + }, + [fallbackSrc, hasTriedFallback, onErrorFromProps], + ); + + return { + status, + imgSrc, + handleLoad, + handleError, + }; +} diff --git a/packages/image/src/index.ts b/packages/image/src/index.ts new file mode 100644 index 00000000..9da2343d --- /dev/null +++ b/packages/image/src/index.ts @@ -0,0 +1,2 @@ +export * from './hooks/useImageStatus'; +export * from './Image'; diff --git a/packages/image/tsconfig.json b/packages/image/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/image/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/image/tsup.config.ts b/packages/image/tsup.config.ts new file mode 100644 index 00000000..c533199b --- /dev/null +++ b/packages/image/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + clean: true, + dts: true, + format: ['esm', 'cjs'], +}); diff --git a/packages/image/vitest.config.ts b/packages/image/vitest.config.ts new file mode 100644 index 00000000..e663baf0 --- /dev/null +++ b/packages/image/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.config'; + +export default mergeConfig( + defaultConfig, + defineProject({ + test: { + setupFiles: './vitest.setup.ts', + }, + }), +); diff --git a/packages/image/vitest.setup.ts b/packages/image/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/packages/image/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; From 3fea105518d9f866588ddd32213f9801483e3a67 Mon Sep 17 00:00:00 2001 From: minjee kim Date: Tue, 28 Apr 2026 18:06:28 +0900 Subject: [PATCH 2/4] chore: sync lockfile for image package --- pnpm-lock.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85c37dbb..5e1752b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -725,6 +725,36 @@ importers: specifier: 'catalog:' version: 2.1.8(@types/node@22.10.1)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0) + packages/image: + devDependencies: + '@testing-library/jest-dom': + specifier: 'catalog:' + version: 6.6.3 + '@testing-library/react': + specifier: 'catalog:' + version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: catalog:react + version: 18.3.13 + happy-dom: + specifier: 'catalog:' + version: 15.11.7 + react: + specifier: catalog:react + version: 18.3.1 + react-dom: + specifier: catalog:react + version: 18.3.1(react@18.3.1) + tsup: + specifier: 'catalog:' + version: 8.5.1(jiti@2.4.1)(postcss@8.5.9)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) + typescript: + specifier: 'catalog:' + version: 5.6.3 + vitest: + specifier: 'catalog:' + version: 2.1.8(@types/node@22.10.1)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0) + packages/input: dependencies: '@radix-ui/react-slot': From c426061c8d5a01b3b94056b8c7e9ac57cb4e9672 Mon Sep 17 00:00:00 2001 From: minjee kim Date: Wed, 13 May 2026 20:23:06 +0900 Subject: [PATCH 3/4] fix(image): apply PR review feedback for Image - Sync imgSrc and loading state when src prop changes - Remove hasTriedFallback; use fallback status for placeholder visibility - Migrate styles to Vanilla Extract (fill/fit/hidden, sized + inline vars) - Use shared tsup config; remove obsolete global.d.ts - Add forwardRef to the underlying img element - Tighten onError typing (omit from rest, explicit handler type) - Stop exporting useImageStatus from the main package entry - Align @sipe-team/side aggregate exports with @sipe-team/image BREAKING CHANGE: useImageStatus is no longer exported from @sipe-team/image main entry --- packages/image/global.d.ts | 1 - packages/image/package.json | 5 ++ packages/image/src/Image.css.ts | 26 +++++++ packages/image/src/Image.test.tsx | 67 +++++++++++++++++ packages/image/src/Image.tsx | 83 +++++++++++++--------- packages/image/src/hooks/useImageStatus.ts | 15 ++-- packages/image/src/index.ts | 1 - packages/image/tsup.config.ts | 9 +-- packages/side/package.json | 1 + packages/side/src/index.ts | 1 + packages/side/styles.css | 1 + pnpm-lock.yaml | 13 ++++ 12 files changed, 174 insertions(+), 49 deletions(-) delete mode 100644 packages/image/global.d.ts create mode 100644 packages/image/src/Image.css.ts diff --git a/packages/image/global.d.ts b/packages/image/global.d.ts deleted file mode 100644 index 60260a3a..00000000 --- a/packages/image/global.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '*.module.css'; diff --git a/packages/image/package.json b/packages/image/package.json index 40b2bdcf..0b1c6798 100644 --- a/packages/image/package.json +++ b/packages/image/package.json @@ -19,10 +19,15 @@ "typecheck": "tsc", "prepack": "pnpm run build" }, + "dependencies": { + "clsx": "catalog:" + }, "devDependencies": { "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:react", + "@vanilla-extract/css": "catalog:", + "@vanilla-extract/dynamic": "catalog:", "happy-dom": "catalog:", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/image/src/Image.css.ts b/packages/image/src/Image.css.ts new file mode 100644 index 00000000..74823c40 --- /dev/null +++ b/packages/image/src/Image.css.ts @@ -0,0 +1,26 @@ +import { createVar, fallbackVar, style, styleVariants } from '@vanilla-extract/css'; + +export const widthVar = createVar(); +export const heightVar = createVar(); + +export const sized = style({ + width: fallbackVar(widthVar, 'auto'), + height: fallbackVar(heightVar, 'auto'), +}); + +export const fit = styleVariants({ + contain: { objectFit: 'contain' }, + cover: { objectFit: 'cover' }, + fill: { objectFit: 'fill' }, +}); + +export const fill = style({ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', +}); + +export const hidden = style({ + visibility: 'hidden', +}); diff --git a/packages/image/src/Image.test.tsx b/packages/image/src/Image.test.tsx index 4b23d0a8..919c2cb7 100644 --- a/packages/image/src/Image.test.tsx +++ b/packages/image/src/Image.test.tsx @@ -1,3 +1,5 @@ +import { createRef } from 'react'; + import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -12,6 +14,27 @@ describe('Image', () => { expect(img).toHaveAttribute('src', 'https://picsum.photos/400/300'); }); + it('updates img src when the Image src prop changes after mount', () => { + const firstSrc = 'https://picsum.photos/id/10/200/300'; + const secondSrc = 'https://picsum.photos/id/20/200/300'; + + const { rerender } = render(gallery); + const img = screen.getByRole('img', { name: 'gallery' }); + expect(img).toHaveAttribute('src', firstSrc); + + rerender(gallery); + expect(img).toHaveAttribute('src', secondSrc); + }); + + it('forwards ref to the img element', () => { + const ref = createRef(); + render(ref test); + + expect(ref.current).toBeInstanceOf(HTMLImageElement); + expect(ref.current?.tagName).toBe('IMG'); + expect(ref.current).toHaveAttribute('alt', 'ref test'); + }); + it('applies width and height styles for number and string values', () => { render(size test); @@ -22,6 +45,20 @@ describe('Image', () => { }); }); + it('applies width only; height falls back to auto (CSS variables + sized)', () => { + render(width only); + + const img = screen.getByRole('img', { name: 'width only' }); + expect(img).toHaveStyle({ width: '200px', height: 'auto' }); + }); + + it('applies height only; width falls back to auto (CSS variables + sized)', () => { + render(height only); + + const img = screen.getByRole('img', { name: 'height only' }); + expect(img).toHaveStyle({ width: 'auto', height: '120px' }); + }); + it('switches to fallbackSrc once when image load fails', () => { render( { }); }); + it('prefers fill layout over width prop (no sized / no pixel width from props)', () => { + render( +
+ fill and width +
, + ); + + const img = screen.getByRole('img', { name: 'fill and width' }); + expect(img).toHaveStyle({ + position: 'absolute', + width: '100%', + height: '100%', + }); + expect(img).not.toHaveStyle({ width: '200px' }); + }); + + it('applies object-fit from fit prop', () => { + render( + object-fit contain, + ); + + expect(screen.getByRole('img', { name: 'object-fit contain' })).toHaveStyle({ objectFit: 'contain' }); + }); + + it('applies object-fit fill variant from fit prop', () => { + render(object-fit fill); + + expect(screen.getByRole('img', { name: 'object-fit fill' })).toHaveStyle({ objectFit: 'fill' }); + }); + it('uses lazy loading by default', () => { render(loading attr test); diff --git a/packages/image/src/Image.tsx b/packages/image/src/Image.tsx index 5c2f7dc4..42355ee5 100644 --- a/packages/image/src/Image.tsx +++ b/packages/image/src/Image.tsx @@ -1,12 +1,17 @@ -import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'; +import { type ComponentPropsWithoutRef, type ForwardedRef, forwardRef, type ReactNode } from 'react'; + +import { assignInlineVars } from '@vanilla-extract/dynamic'; + +import { clsx as cx } from 'clsx'; import { useImageStatus } from './hooks/useImageStatus'; +import * as styles from './Image.css'; type ImageSize = number | string; type ImageFit = 'contain' | 'cover' | 'fill'; export interface ImageProps - extends Omit, 'src' | 'alt' | 'width' | 'height' | 'onLoad'> { + extends Omit, 'src' | 'alt' | 'width' | 'height' | 'onLoad' | 'onError'> { src: string; alt: string; width?: ImageSize; @@ -15,58 +20,68 @@ export interface ImageProps fill?: boolean; fallbackSrc?: string; placeholder?: ReactNode; + onError?: ComponentPropsWithoutRef<'img'>['onError']; } -export function Image({ - src, - alt, - width, - height, - fit = 'cover', - fill = false, - fallbackSrc, - placeholder, - loading = 'lazy', - onError, - style, - ...props -}: ImageProps) { +export const Image = forwardRef(function Image( + { + src, + alt, + width, + height, + fit = 'cover', + fill = false, + fallbackSrc, + placeholder, + loading = 'lazy', + onError, + className: _className, + style, + ...props + }: ImageProps, + ref: ForwardedRef, +) { const { status, imgSrc, handleLoad, handleError } = useImageStatus({ src, ...(fallbackSrc ? { fallbackSrc } : {}), ...(onError ? { onError } : {}), }); - const showPlaceholder = status === 'loading' && placeholder !== undefined; + const showPlaceholder = (status === 'loading' || status === 'fallback') && placeholder !== undefined; const isHidden = showPlaceholder || status === 'error'; - const sizeStyle: CSSProperties = { - width: typeof width === 'number' ? `${width}px` : width, - height: typeof height === 'number' ? `${height}px` : height, - objectFit: fit, - }; - - const fillStyle: CSSProperties = fill - ? { - position: 'absolute', - inset: 0, - width: '100%', - height: '100%', - } - : {}; + const useSized = !fill && (width !== undefined || height !== undefined); + const dimensionStyle = + useSized && + assignInlineVars({ + ...(width !== undefined && { + [styles.widthVar]: typeof width === 'number' ? `${width}px` : width, + }), + ...(height !== undefined && { + [styles.heightVar]: typeof height === 'number' ? `${height}px` : height, + }), + }); return ( <> {showPlaceholder ? placeholder : null} {alt} ); -} +}); diff --git a/packages/image/src/hooks/useImageStatus.ts b/packages/image/src/hooks/useImageStatus.ts index ddbc0d32..fd3fd442 100644 --- a/packages/image/src/hooks/useImageStatus.ts +++ b/packages/image/src/hooks/useImageStatus.ts @@ -1,4 +1,4 @@ -import { type SyntheticEvent, useCallback, useState } from 'react'; +import { type SyntheticEvent, useCallback, useEffect, useState } from 'react'; export type ImageStatus = 'loading' | 'normal' | 'fallback' | 'error'; @@ -22,7 +22,11 @@ export function useImageStatus({ }: UseImageStatusParams): UseImageStatusResult { const [status, setStatus] = useState('loading'); const [imgSrc, setImgSrc] = useState(src); - const [hasTriedFallback, setHasTriedFallback] = useState(false); + + useEffect(() => { + setImgSrc(src); + setStatus('loading'); + }, [src]); const handleLoad = useCallback((_event: SyntheticEvent) => { setStatus('normal'); @@ -32,16 +36,15 @@ export function useImageStatus({ (event: SyntheticEvent) => { onErrorFromProps?.(event); - if (!hasTriedFallback && fallbackSrc) { + if (status !== 'fallback' && fallbackSrc) { setImgSrc(fallbackSrc); - setHasTriedFallback(true); - setStatus('loading'); + setStatus('fallback'); return; } setStatus('error'); }, - [fallbackSrc, hasTriedFallback, onErrorFromProps], + [fallbackSrc, onErrorFromProps, status], ); return { diff --git a/packages/image/src/index.ts b/packages/image/src/index.ts index 9da2343d..4bbac901 100644 --- a/packages/image/src/index.ts +++ b/packages/image/src/index.ts @@ -1,2 +1 @@ -export * from './hooks/useImageStatus'; export * from './Image'; diff --git a/packages/image/tsup.config.ts b/packages/image/tsup.config.ts index c533199b..ee4b1170 100644 --- a/packages/image/tsup.config.ts +++ b/packages/image/tsup.config.ts @@ -1,8 +1,3 @@ -import { defineConfig } from 'tsup'; +import defaultConfig from '../../tsup.config'; -export default defineConfig({ - entry: ['src/index.ts'], - clean: true, - dts: true, - format: ['esm', 'cjs'], -}); +export default defaultConfig; diff --git a/packages/side/package.json b/packages/side/package.json index 53643b97..717f9c73 100644 --- a/packages/side/package.json +++ b/packages/side/package.json @@ -25,6 +25,7 @@ "@sipe-team/button": "workspace:*", "@sipe-team/card": "workspace:*", "@sipe-team/divider": "workspace:*", + "@sipe-team/image": "workspace:*", "@sipe-team/input": "workspace:*", "@sipe-team/radio": "workspace:*", "@sipe-team/skeleton": "workspace:*", diff --git a/packages/side/src/index.ts b/packages/side/src/index.ts index 0fea7569..c02857d9 100644 --- a/packages/side/src/index.ts +++ b/packages/side/src/index.ts @@ -3,6 +3,7 @@ export * from '@sipe-team/button'; export * from '@sipe-team/card'; export * from '@sipe-team/divider'; export * from '@sipe-team/flex'; +export * from '@sipe-team/image'; export * from '@sipe-team/input'; export * from '@sipe-team/radio'; export * from '@sipe-team/skeleton'; diff --git a/packages/side/styles.css b/packages/side/styles.css index b1b1b2ae..8446dd9f 100644 --- a/packages/side/styles.css +++ b/packages/side/styles.css @@ -3,6 +3,7 @@ @import "~@sipe-team/card/styles.css"; @import "~@sipe-team/divider/styles.css"; @import "~@sipe-team/flex/styles.css"; +@import "~@sipe-team/image/styles.css"; @import "~@sipe-team/input/styles.css"; @import "~@sipe-team/radio/styles.css"; @import "~@sipe-team/skeleton/styles.css"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e1752b5..312a3e12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,6 +726,10 @@ importers: version: 2.1.8(@types/node@22.10.1)(happy-dom@15.11.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.37.0) packages/image: + dependencies: + clsx: + specifier: 'catalog:' + version: 2.1.1 devDependencies: '@testing-library/jest-dom': specifier: 'catalog:' @@ -736,6 +740,12 @@ importers: '@types/react': specifier: catalog:react version: 18.3.13 + '@vanilla-extract/css': + specifier: 'catalog:' + version: 1.20.1 + '@vanilla-extract/dynamic': + specifier: 'catalog:' + version: 2.1.5 happy-dom: specifier: 'catalog:' version: 15.11.7 @@ -904,6 +914,9 @@ importers: '@sipe-team/flex': specifier: workspace:* version: link:../flex + '@sipe-team/image': + specifier: workspace:* + version: link:../image '@sipe-team/input': specifier: workspace:* version: link:../input From ac2eb9968bd478aa3e414fbe9c0dbf80de0f8439 Mon Sep 17 00:00:00 2001 From: minjee kim Date: Wed, 13 May 2026 20:40:17 +0900 Subject: [PATCH 4/4] chore(changeset): add changeset for @sipe-team/image and @sipe-team/side --- .changeset/tidy-planets-divide.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tidy-planets-divide.md diff --git a/.changeset/tidy-planets-divide.md b/.changeset/tidy-planets-divide.md new file mode 100644 index 00000000..cb4a59da --- /dev/null +++ b/.changeset/tidy-planets-divide.md @@ -0,0 +1,6 @@ +--- +"@sipe-team/image": minor +"@sipe-team/side": minor +--- + +Add @sipe-team/image and refine Image after PR review (styles, ref, state, exports, docs)