diff --git a/.changeset/ninety-icons-invite.md b/.changeset/ninety-icons-invite.md new file mode 100644 index 0000000..65b3c64 --- /dev/null +++ b/.changeset/ninety-icons-invite.md @@ -0,0 +1,6 @@ +--- +"@sipe-team/flex": minor +"@sipe-team/side": minor +--- + +feat(flex): add flex component diff --git a/packages/flex/.storybook/main.ts b/packages/flex/.storybook/main.ts new file mode 100644 index 0000000..2f0a252 --- /dev/null +++ b/packages/flex/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +export default { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +} satisfies StorybookConfig; diff --git a/packages/flex/.storybook/preview.ts b/packages/flex/.storybook/preview.ts new file mode 100644 index 0000000..82ec7ed --- /dev/null +++ b/packages/flex/.storybook/preview.ts @@ -0,0 +1,5 @@ +import type { Preview } from '@storybook/react'; + +export default { + tags: ['autodocs'], +} satisfies Preview; diff --git a/packages/flex/global.d.ts b/packages/flex/global.d.ts new file mode 100644 index 0000000..60260a3 --- /dev/null +++ b/packages/flex/global.d.ts @@ -0,0 +1 @@ +declare module '*.module.css'; diff --git a/packages/flex/package.json b/packages/flex/package.json new file mode 100644 index 0000000..64f2e31 --- /dev/null +++ b/packages/flex/package.json @@ -0,0 +1,68 @@ +{ + "name": "@sipe-team/flex", + "description": "Flex for Sipe Design System", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sipe-team/3-2_side" + }, + "type": "module", + "exports": "./src/index.ts", + "files": ["dist"], + "scripts": { + "build": "tsup", + "build:storybook": "storybook build", + "dev:storybook": "storybook dev -p 6006", + "lint:biome": "pnpm exec biome lint", + "lint:eslint": "pnpm exec eslint --flag unstable_ts_config", + "test": "vitest", + "typecheck": "tsc", + "prepack": "pnpm run build" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@faker-js/faker": "^9.2.0", + "@sipe-team/card": "workspace:^", + "@storybook/addon-essentials": "catalog:", + "@storybook/addon-interactions": "catalog:", + "@storybook/addon-links": "catalog:", + "@storybook/blocks": "catalog:", + "@storybook/react": "catalog:", + "@storybook/react-vite": "catalog:", + "@storybook/test": "catalog:", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/react": "^18.3.12", + "happy-dom": "catalog:", + "react": "^18.3.1", + "storybook": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">= 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/styles.css" + } + }, + "sideEffects": false +} diff --git a/packages/flex/src/Flex.module.css b/packages/flex/src/Flex.module.css new file mode 100644 index 0000000..22d5bed --- /dev/null +++ b/packages/flex/src/Flex.module.css @@ -0,0 +1,11 @@ +.flex { + display: var(--flex-display); + flex-direction: var(--flex-direction); + align-items: var(--flex-align); + justify-content: var(--flex-justify); + flex-wrap: var(--flex-wrap); + gap: var(--flex-gap); + flex-basis: var(--flex-basis); + flex-grow: var(--flex-grow); + flex-shrink: var(--flex-shrink); +} diff --git a/packages/flex/src/Flex.stories.tsx b/packages/flex/src/Flex.stories.tsx new file mode 100644 index 0000000..8195b96 --- /dev/null +++ b/packages/flex/src/Flex.stories.tsx @@ -0,0 +1,172 @@ +import { Card } from '@sipe-team/card'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Flex } from './Flex'; + +const meta = { + title: 'Flex', + component: Flex, + tags: ['autodocs'], + argTypes: { + direction: { + control: 'select', + options: ['row', 'column', 'row-reverse', 'column-reverse'], + description: 'Flex direction', + }, + align: { + control: 'select', + options: ['flex-start', 'flex-end', 'center', 'stretch', 'baseline'], + description: 'Align items', + }, + justify: { + control: 'select', + options: [ + 'flex-start', + 'flex-end', + 'center', + 'space-between', + 'space-around', + 'space-evenly', + ], + description: 'Justify content', + }, + wrap: { + control: 'select', + options: ['nowrap', 'wrap', 'wrap-reverse'], + description: 'Flex wrap', + }, + gap: { + control: 'text', + description: 'Gap between items', + }, + inline: { + control: 'boolean', + description: 'Display as inline-flex', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + gap: '1rem', + children: [ + , + , + , + ], + }, +}; + +export const Direction: Story = { + args: { + direction: 'column', + gap: '1rem', + style: { width: '100%' }, + children: [ + + 1 + , + + 2 + , + + 3 + , + ], + }, +}; + +export const Align: Story = { + args: { + align: 'center', + gap: '1rem', + style: { width: '100%' }, + children: [ + , + , + , + ], + }, +}; + +export const Justify: Story = { + render: () => ( + + + + flex-start + + + + + center + + + + + flex-end + + + + + space-between + + + + + space-around + + + + + space-evenly + + + + ), +}; + +export const Wrap: Story = { + args: { + wrap: 'wrap', + gap: '1rem', + style: { maxWidth: '400px' }, + children: [ + , + , + , + ], + }, +}; diff --git a/packages/flex/src/Flex.test.tsx b/packages/flex/src/Flex.test.tsx new file mode 100644 index 0000000..2554e53 --- /dev/null +++ b/packages/flex/src/Flex.test.tsx @@ -0,0 +1,254 @@ +import { faker } from '@faker-js/faker'; +import { render, screen } from '@testing-library/react'; +import { type CSSProperties, createElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Flex } from './Flex'; + +describe('Flex', () => { + it('flex 컴포넌트는 기본적으로 flex 속성을 가지고 있다.', () => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toBeInTheDocument(); + expect(flexContainer).toHaveStyle({ display: 'flex' }); + }); + + it('flex 컴포넌트에 className을 주입하면 추가로 전달한다.', () => { + const customClassName = faker.word.noun(); + render(); + expect(screen.getByTestId('flex-container')).toHaveClass(customClassName); + }); + + describe('flex 속성', () => { + describe('justify', () => { + it.each([ + { justifyContent: 'flex-start' }, + { justifyContent: 'flex-end' }, + { justifyContent: 'center' }, + { justifyContent: 'space-between' }, + { justifyContent: 'space-around' }, + { justifyContent: 'space-evenly' }, + ] satisfies Array<{ justifyContent: CSSProperties['justifyContent'] }>)( + 'flex의 justify prop에 $justifyContent 속성을 주입하면 해당 속성을 적용한다.', + ({ justifyContent }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ justifyContent }); + }, + ); + }); + + describe('align', () => { + it.each([ + { alignItems: 'flex-start' }, + { alignItems: 'flex-end' }, + { alignItems: 'center' }, + { alignItems: 'baseline' }, + { alignItems: 'stretch' }, + ] satisfies Array<{ alignItems: CSSProperties['alignItems'] }>)( + 'flex의 align prop에 $alignItems 속성을 주입하면 해당 속성을 적용한다.', + ({ alignItems }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ alignItems }); + }, + ); + }); + + describe('wrap', () => { + it.each([ + { wrap: 'wrap' }, + { wrap: 'nowrap' }, + { wrap: 'wrap-reverse' }, + ] satisfies Array<{ wrap: CSSProperties['flexWrap'] }>)( + 'flex의 wrap prop에 $wrap 속성을 주입하면 해당 속성을 적용한다.', + ({ wrap }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexWrap: wrap }); + }, + ); + }); + + describe('direction', () => { + it.each([ + { direction: 'row' }, + { direction: 'column' }, + { direction: 'row-reverse' }, + { direction: 'column-reverse' }, + { direction: 'column-reverse' }, + ] satisfies Array<{ direction: CSSProperties['flexDirection'] }>)( + 'flex의 direction prop에 $direction 속성을 주입하면 해당 속성을 적용한다.', + ({ direction }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexDirection: direction }); + }, + ); + + describe('basis', () => { + it.each([ + { basis: '100px' }, + { basis: '100%' }, + { basis: 'auto' }, + { basis: '10rem' }, + { basis: 'content' }, + ] satisfies Array<{ + basis: CSSProperties['flexBasis']; + }>)( + 'flex의 basis prop에 $basis 속성을 주입하면 해당 속성을 적용한다.', + ({ basis }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexBasis: basis }); + }, + ); + }); + + describe('grow', () => { + it.each([{ grow: 0 }, { grow: 1 }, { grow: 2 }] satisfies Array<{ + grow: CSSProperties['flexGrow']; + }>)( + 'flex의 grow prop에 $grow 속성을 주입하면 해당 속성을 적용한다.', + ({ grow }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexGrow: grow }); + }, + ); + }); + + describe('shrink', () => { + it.each([{ shrink: 0 }, { shrink: 1 }, { shrink: 2 }] satisfies Array<{ + shrink: CSSProperties['flexShrink']; + }>)( + 'flex의 shrink prop에 $shrink 속성을 주입하면 해당 속성을 적용한다.', + ({ shrink }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ flexShrink: shrink }); + }, + ); + }); + + describe('inline', () => { + it('flex의 inline prop에 true 속성을 주입하면 해당 속성을 적용한다.', () => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ display: 'inline-flex' }); + }); + }); + + describe('gap', () => { + it.each([{ gap: '10px' }, { gap: '1rem' }] satisfies Array<{ + gap: CSSProperties['gap']; + }>)( + 'flex의 gap prop에 $gap 속성을 주입하면 해당 속성을 적용한다.', + ({ gap }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle({ gap }); + }, + ); + }); + }); + }); + + describe('style', () => { + it.each([ + { style: { justifyContent: 'flex-start' } }, + { style: { alignItems: 'center' } }, + { style: { flexWrap: 'wrap' } }, + { style: { flexDirection: 'column' } }, + ] satisfies Array<{ style: CSSProperties }>)( + 'flex의 style prop에 $style 속성을 주입하면 해당 속성을 적용한다.', + ({ style }) => { + render( + +
item 1
+
item 2
+
, + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toHaveStyle(style); + }, + ); + }); + + describe('polymorphic', () => { + it.each(['span', 'nav', 'button', 'input', 'label', 'div'])( + 'flex의 asChild prop에 true 속성을 주입하면 자식으로 %s 엘리먼트가 전달되면 해당 엘리먼트의 태그로 렌더링된다', + (element) => { + render( + + {createElement(element)} + , + ); + + const flexContainer = screen.getByTestId('flex-container'); + expect(flexContainer).toBeInTheDocument(); + expect(flexContainer.tagName.toLowerCase()).toBe(element); + }, + ); + }); +}); diff --git a/packages/flex/src/Flex.tsx b/packages/flex/src/Flex.tsx new file mode 100644 index 0000000..3a6604f --- /dev/null +++ b/packages/flex/src/Flex.tsx @@ -0,0 +1,68 @@ +import { Slot } from '@radix-ui/react-slot'; +import { clsx as cx } from 'clsx'; +import { + type CSSProperties, + type ComponentProps, + type ForwardedRef, + forwardRef, +} from 'react'; +import styles from './Flex.module.css'; + +export interface FlexProps extends ComponentProps<'div'> { + align?: CSSProperties['alignItems']; + justify?: CSSProperties['justifyContent']; + wrap?: CSSProperties['flexWrap']; + direction?: CSSProperties['flexDirection']; + basis?: CSSProperties['flexBasis']; + grow?: CSSProperties['flexGrow']; + shrink?: CSSProperties['flexShrink']; + inline?: boolean; + gap?: CSSProperties['gap']; + asChild?: boolean; +} + +export const Flex = forwardRef(function Flex( + { + align, + justify, + wrap, + direction, + basis, + grow, + shrink, + inline, + gap, + className, + style, + children, + asChild, + ...rest + }: FlexProps, + ref: ForwardedRef, +) { + const Component = asChild ? Slot : 'div'; + + const flexStyle = { + '--flex-display': inline ? 'inline-flex' : 'flex', + '--flex-direction': direction, + '--flex-align': align, + '--flex-justify': justify, + '--flex-wrap': wrap, + '--flex-gap': gap, + '--flex-basis': basis, + '--flex-grow': grow, + '--flex-shrink': shrink, + ...style, + } as React.CSSProperties; + + return ( + + {children} + + ); +}); diff --git a/packages/flex/src/index.ts b/packages/flex/src/index.ts new file mode 100644 index 0000000..7cf460b --- /dev/null +++ b/packages/flex/src/index.ts @@ -0,0 +1 @@ +export * from './Flex'; diff --git a/packages/flex/tsconfig.json b/packages/flex/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/flex/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/flex/tsup.config.ts b/packages/flex/tsup.config.ts new file mode 100644 index 0000000..ee4b117 --- /dev/null +++ b/packages/flex/tsup.config.ts @@ -0,0 +1,3 @@ +import defaultConfig from '../../tsup.config'; + +export default defaultConfig; diff --git a/packages/flex/vitest.config.ts b/packages/flex/vitest.config.ts new file mode 100644 index 0000000..a917827 --- /dev/null +++ b/packages/flex/vitest.config.ts @@ -0,0 +1,11 @@ +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/flex/vitest.setup.ts b/packages/flex/vitest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/packages/flex/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/side/package.json b/packages/side/package.json index 74366e1..61db942 100644 --- a/packages/side/package.json +++ b/packages/side/package.json @@ -28,7 +28,8 @@ "@sipe-team/switch": "workspace:*", "@sipe-team/tokens": "workspace:*", "@sipe-team/tooltip": "workspace:*", - "@sipe-team/typography": "workspace:*" + "@sipe-team/typography": "workspace:*", + "@sipe-team/flex": "workspace:*" }, "devDependencies": { "tsup": "catalog:", diff --git a/packages/side/src/index.ts b/packages/side/src/index.ts index 91292b7..b69122a 100644 --- a/packages/side/src/index.ts +++ b/packages/side/src/index.ts @@ -9,3 +9,4 @@ export * from '@sipe-team/switch'; export * from '@sipe-team/tokens'; export * from '@sipe-team/tooltip'; export * from '@sipe-team/typography'; +export * from '@sipe-team/flex'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac6389..2b26c55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,6 +515,70 @@ importers: specifier: 'catalog:' version: 2.1.8(@types/node@22.10.1)(happy-dom@15.11.7) + packages/flex: + dependencies: + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.1.0(@types/react@18.3.13)(react@18.3.1) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + devDependencies: + '@faker-js/faker': + specifier: ^9.2.0 + version: 9.2.0 + '@sipe-team/card': + specifier: workspace:^ + version: link:../card + '@storybook/addon-essentials': + specifier: 'catalog:' + version: 8.4.6(@types/react@18.3.13)(storybook@8.4.6(prettier@2.8.8)) + '@storybook/addon-interactions': + specifier: 'catalog:' + version: 8.4.6(storybook@8.4.6(prettier@2.8.8)) + '@storybook/addon-links': + specifier: 'catalog:' + version: 8.4.6(react@18.3.1)(storybook@8.4.6(prettier@2.8.8)) + '@storybook/blocks': + specifier: 'catalog:' + version: 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@2.8.8)) + '@storybook/react': + specifier: 'catalog:' + version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@2.8.8))(typescript@5.7.2) + '@storybook/react-vite': + specifier: 'catalog:' + version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.28.0)(storybook@8.4.6(prettier@2.8.8))(typescript@5.7.2)(vite@5.4.11(@types/node@22.10.1)) + '@storybook/test': + specifier: 'catalog:' + version: 8.4.6(storybook@8.4.6(prettier@2.8.8)) + '@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: ^18.3.12 + version: 18.3.13 + happy-dom: + specifier: 'catalog:' + version: 15.11.7 + react: + specifier: ^18.3.1 + version: 18.3.1 + storybook: + specifier: 'catalog:' + version: 8.4.6(prettier@2.8.8) + tsup: + specifier: 'catalog:' + version: 8.3.5(jiti@2.4.1)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.6.1) + typescript: + specifier: 'catalog:' + version: 5.7.2 + vitest: + specifier: 'catalog:' + version: 2.1.8(@types/node@22.10.1)(happy-dom@15.11.7) + packages/icon: devDependencies: '@storybook/addon-essentials': @@ -640,6 +704,9 @@ importers: '@sipe-team/divider': specifier: workspace:* version: link:../divider + '@sipe-team/flex': + specifier: workspace:* + version: link:../flex '@sipe-team/input': specifier: workspace:* version: link:../Input @@ -4987,7 +5054,7 @@ snapshots: '@eslint/config-array@0.19.1': dependencies: '@eslint/object-schema': 2.1.5 - debug: 4.3.7 + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -4999,7 +5066,7 @@ snapshots: '@eslint/eslintrc@3.2.0': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.4.0 espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2