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) diff --git a/packages/image/package.json b/packages/image/package.json new file mode 100644 index 00000000..0b1c6798 --- /dev/null +++ b/packages/image/package.json @@ -0,0 +1,62 @@ +{ + "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" + }, + "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", + "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.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.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..919c2cb7 --- /dev/null +++ b/packages/image/src/Image.test.tsx @@ -0,0 +1,166 @@ +import { createRef } from 'react'; + +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('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); + + const img = screen.getByRole('img', { name: 'size test' }); + expect(img).toHaveStyle({ + width: '320px', + height: '50%', + }); + }); + + 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( + 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('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); + + 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..42355ee5 --- /dev/null +++ b/packages/image/src/Image.tsx @@ -0,0 +1,87 @@ +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' | 'onError'> { + src: string; + alt: string; + width?: ImageSize; + height?: ImageSize; + fit?: ImageFit; + fill?: boolean; + fallbackSrc?: string; + placeholder?: ReactNode; + onError?: ComponentPropsWithoutRef<'img'>['onError']; +} + +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' || status === 'fallback') && placeholder !== undefined; + const isHidden = showPlaceholder || status === 'error'; + + 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 new file mode 100644 index 00000000..fd3fd442 --- /dev/null +++ b/packages/image/src/hooks/useImageStatus.ts @@ -0,0 +1,56 @@ +import { type SyntheticEvent, useCallback, useEffect, 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); + + useEffect(() => { + setImgSrc(src); + setStatus('loading'); + }, [src]); + + const handleLoad = useCallback((_event: SyntheticEvent) => { + setStatus('normal'); + }, []); + + const handleError = useCallback( + (event: SyntheticEvent) => { + onErrorFromProps?.(event); + + if (status !== 'fallback' && fallbackSrc) { + setImgSrc(fallbackSrc); + setStatus('fallback'); + return; + } + + setStatus('error'); + }, + [fallbackSrc, onErrorFromProps, status], + ); + + 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..4bbac901 --- /dev/null +++ b/packages/image/src/index.ts @@ -0,0 +1 @@ +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..ee4b1170 --- /dev/null +++ b/packages/image/tsup.config.ts @@ -0,0 +1,3 @@ +import defaultConfig from '../../tsup.config'; + +export default defaultConfig; 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'; 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 85c37dbb..312a3e12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -725,6 +725,46 @@ 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: + dependencies: + clsx: + specifier: 'catalog:' + version: 2.1.1 + 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 + '@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 + 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': @@ -874,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