diff --git a/CHANGELOG.md b/CHANGELOG.md index 4053cdc..3bb6473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [0.19.1] - 2026-07-14 + +**Branch**: `ui-v0.19.1-레이아웃및토큰체계정비` +### 추가 +- feat: 기능색상램프토큰추가 +- feat: spacing토큰및레이아웃컴포넌트추가 +- feat: Card컴포넌트variant추가 + +### 변경 +- refactor: Tabs디자인개편및구분선토큰계층정비 +- refactor: Overview쇼케이스및파운데이션타이포토큰정비 +- chore: v0.19.1버전범프 + +--- ## [0.18.1] - 2026-07-13 **Branch**: `ui-v0.18.1-달력및숫자입력컴포넌트추가` diff --git a/package.json b/package.json index fc63636..fd6804f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@youngduck/yd-ui", - "version": "0.18.1", + "version": "0.19.1", "type": "module", "main": "./dist/index.cjs.js", "module": "./dist/index.esm.js", diff --git a/src/Overview.stories.tsx b/src/Overview.stories.tsx index 79f12a0..3381dbf 100644 --- a/src/Overview.stories.tsx +++ b/src/Overview.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import React, { useState } from 'react' import { Button } from './components/Button/Button' +import { Card } from './components/Card/Card' import { Input } from './components/Inputs/Input/Input' import { NumberInput } from './components/Inputs/NumberInput/NumberInput' import { CheckBox } from './components/CheckBox/CheckBox' @@ -11,6 +12,7 @@ import { Tabs } from './components/Tabs/Tabs' import { YearPicker } from './components/Calendars/YearPicker/YearPicker' import { MonthPicker } from './components/Calendars/MonthPicker/MonthPicker' import { DatePicker } from './components/Calendars/DatePicker/DatePicker' +import { Stack } from './components/Layouts/Stack/Stack' import { Table, THead, TBody, Tr, Th, Td } from './components/Table' const meta: Meta = { @@ -76,188 +78,222 @@ const OverviewPage: React.FC = () => { { name: 'secondary-400', value: 'rgb(25,25,31)', class: 'bg-secondary-400' }, ] + const systemColors = [ + { name: 'success / income', varName: '--color-success' }, + { name: 'error / expense', varName: '--color-error' }, + ] + return ( -
- {/* 1행: 로고 + 색상 */} -
-
- YD-UI Logo -

YD - Design System

-
-
-
-

Primary

-
- {primaryColors.map(c => ( -
- ))} -
+
+
+ {/* 1행: 로고 + 색상 */} +
+
+ YD-UI Logo +

YD - Design System

-
-

Secondary

-
- {secondaryColors.map(c => ( -
- ))} + +

Colors

+
+
Primary
+
+ {primaryColors.map(c => ( +
+ ))} +
-
+
+
Secondary
+
+ {secondaryColors.map(c => ( +
+ ))} +
+
+
+
System
+
+ {systemColors.map(c => ( +
+ ))} +
+
+
-
- {/* 2행: Button, Input, CheckBox */} -
-
-

Button

-
- - - -
-
- - - -
-
- -
-
+ {/* 2행: Button, Input, CheckBox */} +
+ +

Button

+
+ + + +
+
+ + + +
+
+ +
+
-
-

Input

-
- - - - - -
-
+ +

Input

+
+ + + + + +
+
-
-

CheckBox

-
- - - - {}} indeterminate shape="square" /> -
-
-
+ +

CheckBox

+
+ + + + {}} indeterminate shape="square" /> +
+
+
- {/* 3행: SelectBox, Chips, Table */} -
-
-

SelectBox

-
- - -
-
+ {/* 3행: SelectBox, Chips, Table */} +
+ +

SelectBox

+
+ + +
+
-
-

Chips

-
- - Fill - - - Outlined - - - Tag - - - Label - -
-
+ +

Chips

+
+ + Fill + + + Outlined + + + Tag + + + Label + +
+
-
-

Table

- - - - - - - - - - - - - - - - - - - - -
NamePositionStatus
김영덕Frontend - - Active - -
홍길동Backend - - Inactive - -
-
-
+ +

Table

+ + + + + + + + + + + + + + + + + + + + +
NamePositionStatus
김영덕Frontend + + Active + +
홍길동Backend + + Inactive + +
+
+
- {/* 4행: Tabs, Calendar */} -
-
-

Calendar

-
-
-
YearPicker
- -
-
-
MonthPicker
- + {/* 4행: Calendar, Tabs, Card */} +
+ +

Calendar

+
+
+
YearPicker
+ +
+
+
MonthPicker
+ +
+
+
DatePicker
+ +
-
-
DatePicker
- -
-
-
+ -
-

Tabs

-
-
-
Large
- -
-
-
Medium
- + +

Tabs

+
+
+
Large
+ +
+
+
Medium
+ +
+
+
Small
+ +
-
-
Small
- + + + +

Card

+
+ + + Outlined + 테두리로 영역을 구분합니다. + + + + + Filled + 배경색으로 영역을 구분합니다. + +
-
-
+ +
) diff --git a/src/components/Card/Card.stories.tsx b/src/components/Card/Card.stories.tsx index cb189f8..2593945 100644 --- a/src/components/Card/Card.stories.tsx +++ b/src/components/Card/Card.stories.tsx @@ -1,13 +1,7 @@ import { Meta, StoryObj } from '@storybook/react' -import { Card } from './Card' -import { copyCodeToClipboard } from '../../storybook/utils' - -const copyCode = (className?: string) => { - const classNameProp = className ? ` className="${className}"` : '' - const code = `import { Card } from '@youngduck/yd-ui';\n\nfunction Example() {\n return (\n \n 카드 내용\n \n );\n}` - - copyCodeToClipboard(code) -} +import { Card, type CardVariant } from './Card' +import { Stack } from '../Layouts/Stack/Stack' +import { Inline } from '../Layouts/Inline/Inline' const meta: Meta = { title: 'Components/Card', @@ -19,22 +13,28 @@ const meta: Meta = { component: ` YD-UI 디자인 시스템의 카드 컴포넌트입니다. +콘텐츠를 담는 면(surface)으로, 테두리로 구분하는 outlined 와 배경색으로 구분하는 filled 두 가지 variant 를 지원합니다. + ## 주요 특징 -- card-navy-50 스타일 고정 (디자인 토큰 기반) -- border, border-radius, padding은 디자인 토큰에서 일괄 적용 -- \`className\`으로 width, height 등 Tailwind 클래스를 자유롭게 지정 가능 -- children으로 자유롭게 구성 가능 +- outlined(테두리, 기본) / filled(배경) 두 가지 variant +- padding, border-radius, 색상은 디자인 토큰에서 일괄 적용 +- \`className\` 으로 width, height 등 Tailwind 클래스를 자유롭게 지정 가능 +- children 으로 자유롭게 구성 가능 ## 사용 가이드 -- Card는 항상 card-navy-50 스타일을 사용합니다. -- \`className\`에 Tailwind 클래스를 전달하여 크기를 조정합니다 (예: "w-64", "h-40", "max-w-md", "min-h-[200px]"). -- \`children\`에 제목, 본문 등 원하는 내용을 넣어 사용합니다. +- 어두운 페이지 배경 위에 놓이는 대시보드 타일·패널·폼 컨테이너에는 \`variant="filled"\` 를 사용합니다. +- 배경과 같은 면 위에서 영역만 구분할 때는 \`variant="outlined"\` 를 사용합니다. `, }, }, }, tags: ['autodocs'], argTypes: { + variant: { + options: ['outlined', 'filled'] as CardVariant[], + control: { type: 'select' }, + description: 'outlined: 테두리로 구분 / filled: 배경색으로 구분', + }, children: { control: 'text', description: '카드 내용', @@ -44,10 +44,7 @@ YD-UI 디자인 시스템의 카드 컴포넌트입니다. }, className: { control: 'text', - description: '추가 CSS 클래스', - table: { - type: { summary: 'string' }, - }, + description: '추가 CSS 클래스 (크기 지정 등)', }, }, } @@ -58,98 +55,72 @@ type Story = StoryObj export const Default: Story = { render: args => ( - {args.children} + + {args.children} + ), args: { + variant: 'outlined', children: '카드 내용', - className: '', }, } -export const Examples = { +export const AllVariants: Story = { + render: () => ( +
+ + + + Outlined + 테두리로 영역을 구분합니다. + + + + + Filled + 배경색으로 영역을 구분합니다. + + + +
+ ), +} + +export const DashboardTiles: Story = { parameters: { - layout: 'fullscreen', docs: { - disable: true, + description: { + story: '대시보드 요약 타일처럼 filled 카드를 그리드로 배치하는 예시입니다.', + }, }, }, - render: () => { - return ( -
-
-

Card Examples

-

- 각 카드를 클릭하면 사용 코드가 클립보드에 복사됩니다. -

- -
-

Width & Height (크기 조정)

-

- className에 Tailwind 클래스를 전달하여 자유롭게 크기를 조정할 수 있습니다. -

-
- {[ - { className: 'w-64', label: 'className: w-64' }, - { className: 'w-full', label: 'className: w-full' }, - { className: 'max-w-md', label: 'className: max-w-md' }, - { className: 'w-64 h-40', label: 'className: w-64 h-40' }, - { className: 'w-full min-h-[200px]', label: 'className: w-full min-h-[200px]' }, - { className: 'max-w-lg h-64', label: 'className: max-w-lg h-64' }, - ].map(({ className, label }) => ( -
copyCode(className)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - copyCode(className) - } - }} - role="button" - tabIndex={0} - aria-label={`${label} 카드 - 클릭 시 코드 복사`} - > - -
- {label} -

클릭하면 코드가 복사됩니다.

-
-
-
- ))} -
-
- -
-

커스텀 내용 예시

-
{ - const code = `import { Card } from '@youngduck/yd-ui';\n\nfunction Example() {\n return (\n \n

카드 제목

\n

자유롭게 children을 넣어 사용할 수 있습니다.

\n
\n );\n}` - - copyCodeToClipboard(code) - }} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - const code = `import { Card } from '@youngduck/yd-ui';\n\nfunction Example() {\n return (\n \n

카드 제목

\n

자유롭게 children을 넣어 사용할 수 있습니다.

\n
\n );\n}` - - copyCodeToClipboard(code) - } - }} - role="button" - tabIndex={0} - aria-label="커스텀 내용 예시 - 클릭 시 코드 복사" - > - -

카드 제목

-

자유롭게 children을 넣어 사용할 수 있습니다.

-
-
-
-
+ render: () => ( +
+
+ {[ + { label: '총 수입', value: '3,200,000원' }, + { label: '총 지출', value: '82,000원' }, + { label: '잔액', value: '3,118,000원' }, + ].map(item => ( + + + {item.label} + {item.value} + + + ))}
- ) - }, +
+ ), +} + +export const WithCustomContent: Story = { + render: () => ( + + +

카드 제목

+

자유롭게 children 을 넣어 사용할 수 있습니다.

+
+
+ ), } diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 9bd5993..76d718a 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,13 +1,36 @@ -import { clsx } from 'clsx' +/** + * 작성자: KYD + * 기능: 콘텐츠를 담는 면(surface) 컴포넌트 + * 프로세스 설명: outlined(테두리) / filled(배경) 두 가지 variant 로 대시보드 타일, 패널, 폼 컨테이너 등에 사용 + */ + +import { cva, type VariantProps } from 'class-variance-authority' + +const cardVariants = cva('yds-card', { + variants: { + variant: { + /** 테두리로 영역을 구분 (기본) */ + outlined: 'yds-card-outlined', + /** 배경색으로 영역을 구분 (대시보드 타일, 패널 등) */ + filled: 'yds-card-filled', + }, + }, + defaultVariants: { + variant: 'outlined', + }, +}) + +// 타입 export +export type CardVariant = VariantProps['variant'] type CardProps = { children: React.ReactNode - className?: string -} & React.ComponentPropsWithoutRef<'div'> +} & React.ComponentPropsWithoutRef<'div'> & + VariantProps -export function Card({ children, className = '', ...props }: CardProps) { +export function Card({ variant, children, className = '', ...props }: CardProps) { return ( -
+
{children}
) diff --git a/src/components/Layouts/Field/Field.stories.tsx b/src/components/Layouts/Field/Field.stories.tsx new file mode 100644 index 0000000..cad81e5 --- /dev/null +++ b/src/components/Layouts/Field/Field.stories.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react' +import { Meta, StoryObj } from '@storybook/react' +import { Field } from './Field' +import { Stack } from '../Stack/Stack' +import { Inline } from '../Inline/Inline' +import { Input } from '../../Inputs/Input/Input' +import { NumberInput } from '../../Inputs/NumberInput/NumberInput' +import { DatePicker } from '../../Calendars/DatePicker/DatePicker' +import { Button } from '../../Button/Button' + +const meta: Meta = { + title: 'Components/Layout/Field', + component: Field, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +YD-UI 디자인 시스템의 폼 필드 컴포넌트입니다. + +입력 하나를 라벨 + 입력 + 설명/에러 메시지의 표준 구조로 묶습니다. 폼을 만들 때마다 라벨 간격과 타이포그래피를 즉흥적으로 정하지 않도록 결정을 컴포넌트에 박제하는 것이 목적입니다. + +## 주요 특징 +- \`label\` + 임의의 입력(children) + \`description\` / \`error\` 메시지 구조 +- \`error\` 지정 시 설명 대신 에러가 표시되고 \`role="alert"\` 로 스크린 리더에 즉시 전달 +- \`required\` 지정 시 라벨 옆에 필수 표시(*) +- label 요소로 감싸므로 네이티브 input 은 라벨 클릭 시 자동 포커스 +- 라벨·메시지의 색상/타이포그래피/간격은 모두 디자인 토큰에서 일괄 적용 + +## 사용 가이드 +- 여러 Field 를 세로로 배치할 때는 Stack 으로 감쌉니다. +- Input / NumberInput / DatePicker / SelectBox 등 어떤 입력이든 children 으로 넣을 수 있습니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + label: { + control: { type: 'text' }, + description: '입력 위에 표시되는 라벨 (필수)', + }, + description: { + control: { type: 'text' }, + description: '입력 아래 보조 설명', + }, + error: { + control: { type: 'text' }, + description: '에러 메시지. 지정 시 설명 대신 표시', + }, + required: { + control: { type: 'boolean' }, + description: '필수 입력 표시(*) 여부', + }, + children: { + control: false, + description: '라벨이 감쌀 입력 요소', + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ + + +
+ ), + args: { + label: '내용', + description: '거래 내역에 표시될 이름입니다.', + required: false, + }, +} + +export const WithError: Story = { + render: () => ( +
+ + {}} suffix="원" placeholder="0" aria-label="금액" /> + +
+ ), +} + +export const WithVariousInputs: Story = { + parameters: { + docs: { + description: { + story: '어떤 입력 컴포넌트든 children 으로 감쌀 수 있습니다.', + }, + }, + }, + render: () => , +} + +function VariousInputsContent() { + const [amount, setAmount] = useState('400000') + const [date, setDate] = useState('2026-07-10') + + return ( + + + + + + + + + + + + ) +} + +export const TransactionFormRecipe: Story = { + parameters: { + layout: 'fullscreen', + docs: { + description: { + story: + '거래 추가 폼 레시피: Stack + Field + Inline 조합으로 실제 화면이 어떻게 나오는지 미리 확인하는 예시입니다.', + }, + }, + }, + render: () => , +} + +function TransactionFormRecipeContent() { + const [amount, setAmount] = useState('') + const [date, setDate] = useState('') + const [submitted, setSubmitted] = useState(false) + + const amountError = submitted && amount === '' ? '금액을 입력하세요.' : undefined + + return ( +
+
+ +

거래 추가

+ + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/src/components/Layouts/Field/Field.tsx b/src/components/Layouts/Field/Field.tsx new file mode 100644 index 0000000..89b145c --- /dev/null +++ b/src/components/Layouts/Field/Field.tsx @@ -0,0 +1,51 @@ +/** + * 작성자: KYD + * 기능: 폼 입력 하나를 라벨 + 입력 + 설명/에러 메시지의 표준 구조로 묶는 컴포넌트 + * 프로세스 설명: label 요소로 감싸므로 네이티브 input 은 라벨 클릭 시 자동으로 포커스됩니다 + */ + +import { useId } from 'react' + +type FieldProps = { + /** 입력 위에 표시되는 라벨 */ + label: string + /** 입력 아래 보조 설명 (에러가 있으면 에러가 우선 표시) */ + description?: string + /** 에러 메시지. 지정 시 설명 대신 표시 */ + error?: string + /** 필수 입력 표시(*) 여부 */ + required?: boolean + /** 라벨이 감쌀 입력 요소 */ + children: React.ReactNode +} & Omit, 'children'> + +export function Field({ label, description, error, required, children, className = '', ...props }: FieldProps) { + const messageId = useId() + + return ( + + ) +} + +Field.displayName = 'Field' diff --git a/src/components/Layouts/Inline/Inline.stories.tsx b/src/components/Layouts/Inline/Inline.stories.tsx new file mode 100644 index 0000000..fa71a33 --- /dev/null +++ b/src/components/Layouts/Inline/Inline.stories.tsx @@ -0,0 +1,130 @@ +import { Meta, StoryObj } from '@storybook/react' +import { Inline, type InlineGap, type InlineAlign, type InlineJustify } from './Inline' +import { Chips } from '../../Chips/Chips' +import { Button } from '../../Button/Button' + +const meta: Meta = { + title: 'Components/Layout/Inline', + component: Inline, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +YD-UI 디자인 시스템의 수평 나열 레이아웃 컴포넌트입니다. + +자식들을 가로로 나열하고(공간이 부족하면 자동 줄바꿈) 사이 간격을 spacing 토큰(sm 4px / md 8px / lg 16px)으로 강제합니다. 버튼 그룹, 칩 목록, 필터 바처럼 가로로 흐르는 배치에 사용합니다. + +## 주요 특징 +- \`gap\`: sm / md / lg 세 가지만 허용 (기본값 md, 토큰화) +- \`align\`: start / center / end 교차축 정렬 (기본값 center) +- \`justify\`: start / center / end / between 주축 정렬 (기본값 start) +- 공간이 부족하면 자동으로 줄바꿈 (flex-wrap) +- 표준 div 속성을 그대로 지원 + +## 사용 가이드 +- 세로 나열은 Stack 컴포넌트를 사용합니다. +- 라벨과 값을 양 끝에 배치할 때는 \`justify="between"\` 을 사용합니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + gap: { + options: ['sm', 'md', 'lg'] as InlineGap[], + control: { type: 'select' }, + description: '자식 사이 수평 간격 (4 / 8 / 16px)', + }, + align: { + options: ['start', 'center', 'end'] as InlineAlign[], + control: { type: 'select' }, + description: '교차축(세로) 정렬', + }, + justify: { + options: ['start', 'center', 'end', 'between'] as InlineJustify[], + control: { type: 'select' }, + description: '주축(가로) 정렬', + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: args => ( + + + 식비 + + + 교통비 + + + 문화생활 + + + ), + args: { + gap: 'md', + align: 'center', + justify: 'start', + }, +} + +export const ButtonGroup: Story = { + parameters: { + docs: { + description: { + story: '모달 하단 버튼 그룹처럼 우측 정렬로 배치하는 예시입니다.', + }, + }, + }, + render: () => ( + + + + + ), +} + +export const SpaceBetween: Story = { + parameters: { + docs: { + description: { + story: '라벨과 값을 양 끝에 배치하는 예시입니다.', + }, + }, + }, + render: () => ( + + 이번 달 총 예산 + ₩1,200,000 + + ), +} + +export const Wrapping: Story = { + parameters: { + docs: { + description: { + story: '공간이 부족하면 자동으로 줄바꿈됩니다.', + }, + }, + }, + render: () => ( + + {['식비', '교통비', '문화생활', '주거', '통신비', '의료', '교육'].map(name => ( + + {name} + + ))} + + ), +} diff --git a/src/components/Layouts/Inline/Inline.tsx b/src/components/Layouts/Inline/Inline.tsx new file mode 100644 index 0000000..9fa5dff --- /dev/null +++ b/src/components/Layouts/Inline/Inline.tsx @@ -0,0 +1,46 @@ +/** + * 작성자: KYD + * 기능: 자식들을 수평으로 나열(공간 부족 시 줄바꿈)하고 간격을 spacing 토큰으로 강제하는 레이아웃 컴포넌트 + * 프로세스 설명: 버튼 그룹, 칩 목록, 필터 바처럼 가로로 흐르는 요소 배치에 사용합니다 + */ + +import { cva, type VariantProps } from 'class-variance-authority' + +const inlineVariants = cva('yds-inline', { + variants: { + gap: { + sm: 'yds-inline-gap-sm', + md: 'yds-inline-gap-md', + lg: 'yds-inline-gap-lg', + }, + align: { + start: 'yds-inline-align-start', + center: 'yds-inline-align-center', + end: 'yds-inline-align-end', + }, + justify: { + start: 'yds-inline-justify-start', + center: 'yds-inline-justify-center', + end: 'yds-inline-justify-end', + between: 'yds-inline-justify-between', + }, + }, + defaultVariants: { + gap: 'md', + align: 'center', + justify: 'start', + }, +}) + +// 타입 export +export type InlineGap = VariantProps['gap'] +export type InlineAlign = VariantProps['align'] +export type InlineJustify = VariantProps['justify'] + +type InlineProps = React.ComponentPropsWithoutRef<'div'> & VariantProps + +export function Inline({ gap, align, justify, className = '', ...props }: InlineProps) { + return
+} + +Inline.displayName = 'Inline' diff --git a/src/components/Layouts/Stack/Stack.stories.tsx b/src/components/Layouts/Stack/Stack.stories.tsx new file mode 100644 index 0000000..e21092e --- /dev/null +++ b/src/components/Layouts/Stack/Stack.stories.tsx @@ -0,0 +1,96 @@ +import { Meta, StoryObj } from '@storybook/react' +import { Stack, type StackGap, type StackAlign } from './Stack' + +const meta: Meta = { + title: 'Components/Layout/Stack', + component: Stack, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +YD-UI 디자인 시스템의 수직 나열 레이아웃 컴포넌트입니다. + +자식들을 세로로 쌓고 사이 간격을 spacing 토큰(sm 8px / md 16px / lg 24px)으로 강제합니다. 화면마다 임의의 gap 값을 즉흥적으로 정하지 않도록 선택지를 좁혀 간격 리듬을 통일하는 것이 목적입니다. + +## 주요 특징 +- \`gap\`: sm / md / lg 세 가지만 허용 (기본값 md, 토큰화) +- \`align\`: start / center / end / stretch 교차축 정렬 (기본값 stretch) +- 표준 div 속성을 그대로 지원 + +## 사용 가이드 +- 폼 필드 사이, 카드 안 콘텐츠 사이 등 수직 간격이 필요한 모든 곳에 사용합니다. +- 가로 나열은 Inline 컴포넌트를 사용합니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + gap: { + options: ['sm', 'md', 'lg'] as StackGap[], + control: { type: 'select' }, + description: '자식 사이 수직 간격 (8 / 16 / 24px)', + }, + align: { + options: ['start', 'center', 'end', 'stretch'] as StackAlign[], + control: { type: 'select' }, + description: '교차축(가로) 정렬', + }, + }, +} + +export default meta + +type Story = StoryObj + +const DemoBox = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) + +export const Default: Story = { + render: args => ( + + 첫 번째 + 두 번째 + 세 번째 + + ), + args: { + gap: 'md', + align: 'stretch', + }, +} + +export const AllGaps: Story = { + render: () => ( +
+ {(['sm', 'md', 'lg'] as StackGap[]).map(gap => ( +
+
{gap}
+ + A + B + C + +
+ ))} +
+ ), +} + +export const AllAligns: Story = { + render: () => ( +
+ {(['start', 'center', 'end', 'stretch'] as StackAlign[]).map(align => ( +
+
{align}
+ + 짧음 + 조금 더 긴 항목 + +
+ ))} +
+ ), +} diff --git a/src/components/Layouts/Stack/Stack.tsx b/src/components/Layouts/Stack/Stack.tsx new file mode 100644 index 0000000..086c1da --- /dev/null +++ b/src/components/Layouts/Stack/Stack.tsx @@ -0,0 +1,39 @@ +/** + * 작성자: KYD + * 기능: 자식들을 수직으로 나열하고 간격을 spacing 토큰으로 강제하는 레이아웃 컴포넌트 + * 프로세스 설명: 화면마다 임의의 gap 값을 쓰지 않도록 sm/md/lg 세 가지 간격만 허용합니다 + */ + +import { cva, type VariantProps } from 'class-variance-authority' + +const stackVariants = cva('yds-stack', { + variants: { + gap: { + sm: 'yds-stack-gap-sm', + md: 'yds-stack-gap-md', + lg: 'yds-stack-gap-lg', + }, + align: { + start: 'yds-stack-align-start', + center: 'yds-stack-align-center', + end: 'yds-stack-align-end', + stretch: 'yds-stack-align-stretch', + }, + }, + defaultVariants: { + gap: 'md', + align: 'stretch', + }, +}) + +// 타입 export +export type StackGap = VariantProps['gap'] +export type StackAlign = VariantProps['align'] + +type StackProps = React.ComponentPropsWithoutRef<'div'> & VariantProps + +export function Stack({ gap, align, className = '', ...props }: StackProps) { + return
+} + +Stack.displayName = 'Stack' diff --git a/src/components/Layouts/index.ts b/src/components/Layouts/index.ts new file mode 100644 index 0000000..d009feb --- /dev/null +++ b/src/components/Layouts/index.ts @@ -0,0 +1,5 @@ +export { Stack } from './Stack/Stack' +export type { StackGap, StackAlign } from './Stack/Stack' +export { Inline } from './Inline/Inline' +export type { InlineGap, InlineAlign, InlineJustify } from './Inline/Inline' +export { Field } from './Field/Field' diff --git a/src/components/Tabs/Tabs.stories.tsx b/src/components/Tabs/Tabs.stories.tsx index f61b87d..32dd53f 100644 --- a/src/components/Tabs/Tabs.stories.tsx +++ b/src/components/Tabs/Tabs.stories.tsx @@ -26,9 +26,10 @@ const meta: Meta = { component: ` YD-UI 디자인 시스템의 탭 컴포넌트입니다. -컨테이너 안에서 여러 항목 중 하나를 선택하는 탭형 토글이며, 선택된 탭은 노란(primary) 배경 + 검정 글씨로 강조됩니다. +컨테이너 안에서 여러 항목 중 하나를 선택하는 탭형 토글이며, 선택된 탭은 노란(primary) 글자로 강조됩니다. ## 주요 특징 +- 구분선 색 테두리를 두른 네이비 컨테이너 안에서 선택 탭은 노란 글자, 미선택 탭은 흰 글자로 표시 - sm / md / lg 사이즈 지원 (토큰화) - 제어 컴포넌트: \`value\` / \`onValueChange\` 로 상태를 관리 - \`options\` 배열로 탭을 구성하며 탭별 \`disabled\` 지원 diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index cccfdbb..c5d957c 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -1,8 +1,5 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { cva, type VariantProps } from 'class-variance-authority' - -// SSR(Next.js 등) 서버 렌더 시 useLayoutEffect 경고를 피하기 위한 대체 -const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect +import { useRef } from 'react' const tabsVariants = cva('yds-tabs', { variants: { @@ -40,14 +37,8 @@ type TabsProps = { VariantProps export function Tabs({ options, value, onValueChange, size, className = '', ...props }: TabsProps) { - const containerRef = useRef(null) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) - // 선택된 탭 위로 부드럽게 이동하는 하이라이트(indicator) 위치/크기 - const [indicatorStyle, setIndicatorStyle] = useState({ opacity: 0 }) - // 첫 렌더에서는 슬라이드 애니메이션 없이 제자리에 표시, 이후 상호작용부터 애니메이션 적용 - const [animated, setAnimated] = useState(false) - // 활성화된 탭의 인덱스만 추출 (키보드 이동 시 disabled 건너뛰기 위함) const enabledIndexes = options.map((option, index) => (option.disabled ? -1 : index)).filter(index => index !== -1) @@ -98,53 +89,13 @@ export function Tabs({ options, value, onValueChange, size, className = '', ...p const selectedIndex = options.findIndex(option => option.value === value) const focusableIndex = selectedIndex !== -1 ? selectedIndex : enabledIndexes[0] - // 선택된 탭의 위치/크기를 측정해 indicator에 반영. 선택값이 없으면 숨김. - useIsomorphicLayoutEffect(() => { - const el = selectedIndex !== -1 ? itemRefs.current[selectedIndex] : null - - if (!el) { - setIndicatorStyle(prev => ({ ...prev, opacity: 0 })) - return - } - - const update = () => { - setIndicatorStyle({ - opacity: 1, - width: el.offsetWidth, - height: el.offsetHeight, - transform: `translate(${el.offsetLeft}px, ${el.offsetTop}px)`, - }) - } - - update() - - // 컨테이너/폰트 로드 등으로 크기가 바뀌면 위치 재계산 (Jest/jsdom 등 미지원 환경에서는 생략) - if (typeof ResizeObserver === 'undefined') return - - const observer = new ResizeObserver(update) - if (containerRef.current) observer.observe(containerRef.current) - return () => observer.disconnect() - }, [selectedIndex, size, options]) - - // 마운트 직후부터 슬라이드 애니메이션 활성화 (첫 배치는 애니메이션 없이) - useEffect(() => { - const id = requestAnimationFrame(() => setAnimated(true)) - return () => cancelAnimationFrame(id) - }, []) - return (
-