Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
## [0.22.1] - 2026-08-21

**Branch**: `ui-v0.22.1-PagedCard컴포넌트개발`
### 수정

### 변경
- feat: PagedCard 컴포넌트 추가 (dot 페이저 + 좌우 슬라이드 compound, outlined/filled variant)
- refactor: Card·PagedCard를 components/Cards 그룹 폴더로 재편 (메인 barrel 제외, 서브패스 노출)
- chore: Cards 서브패스(@youngduck/yd-ui/Cards) 배포 엔트리 추가

---
## [0.21.1] - 2026-07-21

**Branch**: `ui-v0.21.1-Tabs디자인개편`
### 변경
- refactor: Tabs컨트롤높이통일및배경·보더토큰정비
- chore: v0.21.1버전범프

---
## [0.21.2] - 2026-07-21

**Branch**: `ui-v0.21.2-모달제목타이포적용`
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@youngduck/yd-ui",
"version": "0.21.2",
"version": "0.22.1",
"type": "module",
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
Expand All @@ -21,6 +21,11 @@
"import": "./dist/Overlays.esm.js",
"require": "./dist/Overlays.cjs.js"
},
"./Cards": {
"types": "./dist/Cards.d.ts",
"import": "./dist/Cards.esm.js",
"require": "./dist/Cards.cjs.js"
},
"./HorizonDragScroll": {
"types": "./dist/HorizonDragScroll.d.ts",
"import": "./dist/HorizonDragScroll.esm.js",
Expand Down
1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const getCommonPlugins = (extractCss = false) => [
const componentEntries = {
Table: 'src/components/Table/index.ts',
Overlays: 'src/components/Overlays/index.ts',
Cards: 'src/components/Cards/index.ts',
HorizonDragScroll: 'src/components/HorizonDragScroll/index.ts',
// 새로운 컴포넌트 추가 시 여기에 추가하면 됨
// SelectBox: 'src/components/SelectBox/index.ts',
Expand Down
39 changes: 38 additions & 1 deletion src/Overview.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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 { Card } from './components/Cards/Card/Card'
import { PagedCard } from './components/Cards/PagedCard'
import { Input } from './components/Inputs/Input/Input'
import { NumberInput } from './components/Inputs/NumberInput/NumberInput'
import { CheckBox } from './components/CheckBox/CheckBox'
Expand Down Expand Up @@ -83,6 +84,8 @@ const OverviewPage: React.FC = () => {
{ name: 'error / expense', varName: '--color-error' },
]

const pageBox = 'flex h-full w-full items-center justify-center text-yds-b1 text-white'

return (
<div className="bg-background-primary min-h-screen p-10">
<div className="mx-auto flex w-[1000px] max-w-full flex-col gap-10">
Expand Down Expand Up @@ -294,6 +297,40 @@ const OverviewPage: React.FC = () => {
</div>
</Card>
</div>

{/* 5행: PagedCard */}
<div className="grid grid-cols-3 gap-8">
<Card variant="outlined" className="col-span-3 min-w-0">
<h2 className="text-yds-h2 text-primary-300 mb-4">PagedCard</h2>
<div className="flex flex-wrap gap-8">
<PagedCard defaultPage={0} className="h-52 w-80">
<PagedCard.Header>Outlined</PagedCard.Header>
<PagedCard.Page>
<div className={pageBox}>outlined 1page</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBox}>outlined 2page</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBox}>outlined 3page</div>
</PagedCard.Page>
</PagedCard>

<PagedCard variant="filled" defaultPage={0} className="h-52 w-80">
<PagedCard.Header>Filled</PagedCard.Header>
<PagedCard.Page>
<div className={pageBox}>filled 1page</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBox}>filled 2page</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBox}>filled 3page</div>
</PagedCard.Page>
</PagedCard>
</div>
</Card>
</div>
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Meta, StoryObj } from '@storybook/react'
import { Card, type CardVariant } from './Card'
import { Stack } from '../Layouts/Stack/Stack'
import { Inline } from '../Layouts/Inline/Inline'
import { Stack } from '../../Layouts/Stack/Stack'
import { Inline } from '../../Layouts/Inline/Inline'

const meta: Meta<typeof Card> = {
title: 'Components/Card',
Expand Down
File renamed without changes.
119 changes: 119 additions & 0 deletions src/components/Cards/PagedCard/PagedCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'

import { PagedCard } from './PagedCard'

const meta: Meta<typeof PagedCard> = {
title: 'Components/PagedCard',
component: PagedCard,
parameters: {
layout: 'centered',
docs: {
description: {
component: `
YD-UI 디자인 시스템의 페이지네이션 카드 컴포넌트입니다.

Card(surface) 위에 헤더(제목 + 우측 상단 dot 페이저)와 좌우로 넘기는 페이지 본문을 얹은 합성(compound) 컴포넌트입니다.

## 주요 특징
- \`PagedCard\` / \`PagedCard.Header\` / \`PagedCard.Page\` 의 compound 구조 (Context 로 현재 페이지 공유)
- 우측 상단 **dot 클릭** 으로 페이지 전환
- 전환 애니메이션은 별도 라이브러리 없이 CSS transform(translateX) transition 으로 처리 (\`prefers-reduced-motion\` 대응)
- \`page\`/\`onPageChange\`(controlled), \`defaultPage\`(uncontrolled) 모두 지원
- 표면은 Card 를 재사용하므로 \`variant\`(outlined/filled) 전달 가능

## 사용 예시
\`\`\`tsx
<PagedCard defaultPage={0} className="w-80 h-64">
<PagedCard.Header>Stages</PagedCard.Header>
<PagedCard.Page>...1페이지...</PagedCard.Page>
<PagedCard.Page>...2페이지...</PagedCard.Page>
</PagedCard>
\`\`\`
`,
},
},
},
tags: ['autodocs'],
}

export default meta

type Story = StoryObj<typeof PagedCard>

const pageBoxClass = 'flex h-full w-full items-center justify-center text-yds-b1 text-white'

export const Default: Story = {
render: () => (
<PagedCard defaultPage={0} className="h-64 w-80">
<PagedCard.Header>Stages</PagedCard.Header>
<PagedCard.Page>
<div className={pageBoxClass}>1 페이지</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBoxClass}>2 페이지</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBoxClass}>3 페이지</div>
</PagedCard.Page>
</PagedCard>
),
}

export const Filled: Story = {
parameters: {
docs: {
description: {
story: '배경색으로 영역을 구분하는 filled variant 입니다. (기본값은 Card 와 동일하게 outlined)',
},
},
},
render: () => (
<PagedCard variant="filled" defaultPage={0} className="h-64 w-80">
<PagedCard.Header>Filled</PagedCard.Header>
<PagedCard.Page>
<div className={pageBoxClass}>A</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBoxClass}>B</div>
</PagedCard.Page>
</PagedCard>
),
}

export const Controlled: Story = {
parameters: {
docs: {
description: {
story: 'page/onPageChange 로 외부 상태와 동기화하는 제어 모드 예시입니다.',
},
},
},
render: () => {
const [page, setPage] = useState(0)
return (
<div className="flex flex-col items-center gap-4">
<PagedCard page={page} onPageChange={setPage} className="h-64 w-80">
<PagedCard.Header>현재: {page + 1}페이지</PagedCard.Header>
<PagedCard.Page>
<div className={pageBoxClass}>1</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBoxClass}>2</div>
</PagedCard.Page>
<PagedCard.Page>
<div className={pageBoxClass}>3</div>
</PagedCard.Page>
</PagedCard>
<div className="flex gap-2">
<button type="button" className="text-yds-c1m text-white" onClick={() => setPage((p) => Math.max(p - 1, 0))}>
이전
</button>
<button type="button" className="text-yds-c1m text-white" onClick={() => setPage((p) => Math.min(p + 1, 2))}>
다음
</button>
</div>
</div>
)
},
}
97 changes: 97 additions & 0 deletions src/components/Cards/PagedCard/PagedCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* 작성자: KYD
* 기능: 여러 페이지를 담고 우측 상단 dot 클릭으로 전환하는 카드 컴포넌트
* 프로세스 설명:
* - yd-ui Card(surface)를 감싸 헤더(제목 + dot 페이저) / 뷰포트(트랙 슬라이드)를 구성
* - Context(PagedCardContext)로 current/count/goTo 를 하위(Header·Dots)에 공유 (Table 과 동일한 compound 패턴)
* - 페이지 전환 애니메이션은 별도 라이브러리 없이 CSS transform translateX + transition 으로 처리
* - page/onPageChange 로 제어(controlled), defaultPage 로 비제어(uncontrolled) 모두 지원
*/
import { clsx } from 'clsx'
import { Children, isValidElement, useState } from 'react'

import { Card, type CardVariant } from '../Card/Card'
import { PagedCardContext } from './context'
import { PagedCardDots } from './PagedCardDots'
import { PagedCardHeader } from './PagedCardHeader'
import { PagedCardPage } from './PagedCardPage'

export interface PagedCardProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
children: React.ReactNode
/** 카드 표면 variant (Card 와 동일하게 기본 outlined) */
variant?: CardVariant
/** 제어 모드: 현재 페이지 인덱스 */
page?: number
/** 비제어 모드: 초기 페이지 인덱스 (기본 0) */
defaultPage?: number
/** 페이지 변경 콜백 */
onPageChange?: (index: number) => void
}

const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)

function PagedCardRoot({
children,
variant = 'outlined',
page,
defaultPage = 0,
onPageChange,
className,
...props
}: PagedCardProps) {
const childArray = Children.toArray(children)
const headers = childArray.filter((child) => isValidElement(child) && child.type === PagedCardHeader)
const pages = childArray.filter((child) => isValidElement(child) && child.type === PagedCardPage)
const count = pages.length

// 개발 모드 경고: 직접 자식은 PagedCard.Header / PagedCard.Page 만 인식된다.
// Fragment(<>...</>)나 다른 컴포넌트로 감싼 자식은 타입이 달라 조용히 무시되므로 미리 알린다.
if (process.env.NODE_ENV !== 'production') {
const unrecognized = childArray.filter(
(child) => isValidElement(child) && child.type !== PagedCardHeader && child.type !== PagedCardPage,
)
if (unrecognized.length > 0) {
console.warn(
'[PagedCard] 직접 자식은 <PagedCard.Header> 또는 <PagedCard.Page> 여야 합니다. ' +
'Fragment(<>...</>)나 다른 컴포넌트로 감싼 자식은 렌더되지 않고 무시됩니다.',
)
}
}
const maxIndex = Math.max(count - 1, 0)

const isControlled = page !== undefined
const [internalPage, setInternalPage] = useState(() => clamp(defaultPage, 0, maxIndex))
const current = clamp(isControlled ? (page as number) : internalPage, 0, maxIndex)

const goTo = (index: number) => {
const next = clamp(index, 0, maxIndex)
if (next === current) return
if (!isControlled) setInternalPage(next)
onPageChange?.(next)
}

return (
<PagedCardContext.Provider value={{ current, count, goTo }}>
<Card variant={variant} className={clsx('yds-paged-card', className)} {...props}>
{headers}
<div className="yds-paged-card-viewport">
<div className="yds-paged-card-track" style={{ transform: `translateX(-${current * 100}%)` }}>
{pages.map((pageNode, index) => (
<div className="yds-paged-card-slide" key={index} aria-hidden={index !== current}>
{pageNode}
</div>
))}
</div>
</div>
</Card>
</PagedCardContext.Provider>
)
}

PagedCardRoot.displayName = 'PagedCard'

export const PagedCard = Object.assign(PagedCardRoot, {
Header: PagedCardHeader,
Page: PagedCardPage,
Dots: PagedCardDots,
})
33 changes: 33 additions & 0 deletions src/components/Cards/PagedCard/PagedCardDots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* 작성자: KYD
* 기능: PagedCard 우측 상단 페이지 인디케이터(동그라미). 클릭으로 해당 페이지 이동
*/
import { clsx } from 'clsx'

import { usePagedCard } from './context'

export type PagedCardDotsProps = React.ComponentPropsWithoutRef<'div'>

export function PagedCardDots({ className, ...props }: PagedCardDotsProps) {
const { current, count, goTo } = usePagedCard()

if (count <= 1) return null

return (
<div {...props} role="tablist" className={clsx('yds-paged-card-dots', className)}>
{Array.from({ length: count }).map((_, index) => (
<button
key={index}
type="button"
role="tab"
aria-selected={index === current}
aria-label={`${index + 1}번째 페이지로 이동`}
className={clsx('yds-paged-card-dot', index === current && 'yds-paged-card-dot-active')}
onClick={() => goTo(index)}
/>
))}
</div>
)
}

PagedCardDots.displayName = 'PagedCard.Dots'
24 changes: 24 additions & 0 deletions src/components/Cards/PagedCard/PagedCardHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* 작성자: KYD
* 기능: PagedCard 헤더. 좌측 제목(children) + 우측 상단 dot 페이저를 배치
*/
import { clsx } from 'clsx'

import { PagedCardDots } from './PagedCardDots'

export interface PagedCardHeaderProps extends React.ComponentPropsWithoutRef<'div'> {
children?: React.ReactNode
/** 우측 상단 dot 페이저 표시 여부 (기본 true) */
showDots?: boolean
}

export function PagedCardHeader({ children, className, showDots = true, ...props }: PagedCardHeaderProps) {
return (
<div {...props} className={clsx('yds-paged-card-header', className)}>
<div className="yds-paged-card-header-title">{children}</div>
{showDots && <PagedCardDots />}
</div>
)
}

PagedCardHeader.displayName = 'PagedCard.Header'
Loading
Loading