feat: storybook 생성

This commit is contained in:
2026-04-13 14:15:00 +09:00
parent b390777af0
commit 01bfc751f2
15 changed files with 1671 additions and 2 deletions

View File

@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
const meta = {
title: 'Components/Button',
component: Button,
argTypes: {
color: {
control: 'select',
options: ['primary', 'light', 'green', 'lightGreen', 'black', 'gray', 'orange', 'navy', 'lightNavy'],
},
size: {
control: 'select',
options: ['small', 'medium', 'large'],
},
isDisabled: {
control: 'boolean',
},
isPending: {
control: 'boolean',
},
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: '버튼',
color: 'primary',
size: 'medium',
},
}
export const Small: Story = {
args: {
children: '작은 버튼',
size: 'small',
},
}
export const Medium: Story = {
args: {
children: '중간 버튼',
size: 'medium',
},
}
export const Large: Story = {
args: {
children: '큰 버튼',
size: 'large',
},
}
export const Primary: Story = {
args: {
children: 'Primary',
color: 'primary',
},
}
export const Light: Story = {
args: {
children: 'Light',
color: 'light',
},
}
export const Green: Story = {
args: {
children: 'Green',
color: 'green',
},
}
export const Navy: Story = {
args: {
children: 'Navy',
color: 'navy',
},
}
export const Disabled: Story = {
args: {
children: '비활성화',
isDisabled: true,
},
}
export const Loading: Story = {
args: {
children: '로딩 중',
isPending: true,
},
}
export const AllColors: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button color="primary">Primary</Button>
<Button color="light">Light</Button>
<Button color="green">Green</Button>
<Button color="lightGreen">Light Green</Button>
<Button color="black">Black</Button>
<Button color="gray">Gray</Button>
<Button color="orange">Orange</Button>
<Button color="navy">Navy</Button>
<Button color="lightNavy">Light Navy</Button>
</div>
),
}

View File

@@ -3,7 +3,6 @@ import type { InputProps as AriaInputProps } from 'react-aria-components';
import { Input as AriaInput } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import style from './style';
export interface InputProps extends AriaInputProps {

View File

@@ -0,0 +1,83 @@
import type { Meta, StoryObj } from '@storybook/react'
import Input from './Input'
import { InputGroup } from './InputGroup'
const meta = {
title: 'Components/InputGroup',
component: InputGroup,
argTypes: {
isReadOnly: { control: 'boolean' },
},
} satisfies Meta<typeof InputGroup>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => (
<InputGroup {...args}>
<Input placeholder="입력해주세요" />
</InputGroup>
),
}
export const WithValue: Story = {
render: (args) => (
<InputGroup {...args}>
<Input defaultValue="입력된 값" />
</InputGroup>
),
}
export const Disabled: Story = {
render: () => (
<InputGroup>
<Input placeholder="비활성화" disabled />
</InputGroup>
),
}
export const ReadOnly: Story = {
args: {
isReadOnly: true,
},
render: (args) => (
<InputGroup {...args}>
<Input defaultValue="읽기 전용" readOnly />
</InputGroup>
),
}
export const NumberInput: Story = {
render: () => (
<InputGroup>
<Input type="number" placeholder="숫자 입력" />
</InputGroup>
),
}
export const WithPrefix: Story = {
render: () => (
<InputGroup>
<span className="pl-3 text-dabeeo-gray-99">@</span>
<Input placeholder="사용자명" />
</InputGroup>
),
}
export const WithSuffix: Story = {
render: () => (
<InputGroup>
<Input placeholder="금액" type="number" />
<span className="pr-3 text-dabeeo-gray-99"></span>
</InputGroup>
),
}
export const CustomWidth: Story = {
render: () => (
<InputGroup className="w-60">
<Input placeholder="너비 240px" />
</InputGroup>
),
}

View File

@@ -0,0 +1,35 @@
import { tv } from 'tailwind-variants';
export default tv({
slots: {
container: 'relative flex flex-col justify-center items-start bg-white',
base: [
'h-9',
'flex',
'items-center',
'text-sm',
'text-dabeeo-black-34',
'leading-[18px]',
'border',
'border-dabeeo-gray-be',
'has-data-focused:border-dabeeo-black-34',
'has-data-hovered:border-dabeeo-black-34',
'has-data-disabled:bg-dabeeo-gray-eb',
'has-data-disabled:text-dabeeo-gray-99',
'has-data-disabled:border-dabeeo-gray-be',
'has-data-invalid:border-dabeeo-red',
'has-data-invalid:has-data-focused:border-dabeeo-red',
'has-data-invalid:has-data-hovered:border-dabeeo-red',
'w-full',
'has-[[type=number]]:leading-6',
],
input: ['w-full', 'px-3', 'outline-0', '[&[type=number]]:pr-1.5'],
},
variants: {
isReadOnly: {
true: {
base: 'bg-dabeeo-yellow-secondary has-data-hovered:border-dabeeo-gray-be has-data-focused:border-dabeeo-gray-be',
},
},
},
});

View File

@@ -0,0 +1,142 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Button } from '../button/Button'
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal'
const meta = {
title: 'Components/Modal',
component: ModalRoot,
argTypes: {
isDismissable: { control: 'boolean' },
isKeyboardDismissDisabled: { control: 'boolean' },
},
} satisfies Meta<typeof ModalRoot>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen}>
<ModalHeader> </ModalHeader>
<ModalBody>
<p className="text-sm"> .</p>
</ModalBody>
<ModalFooter>
<Button color="gray" size="large" onClick={() => setIsOpen(false)}>
</Button>
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
</Button>
</ModalFooter>
</ModalRoot>
</>
)
},
}
export const WithoutCloseButton: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen}>
<ModalHeader hasCloseButton={false}> </ModalHeader>
<ModalBody>
<p className="text-sm"> .</p>
</ModalBody>
<ModalFooter>
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
</Button>
</ModalFooter>
</ModalRoot>
</>
)
},
}
export const LongContent: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen}>
<ModalHeader> </ModalHeader>
<ModalBody className="max-h-60 overflow-y-auto">
{Array.from({ length: 20 }, (_, i) => (
<p key={i} className="text-sm mb-2">
{i + 1}: Lorem ipsum dolor sit amet consectetur adipisicing elit.
</p>
))}
</ModalBody>
<ModalFooter>
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
</Button>
</ModalFooter>
</ModalRoot>
</>
)
},
}
export const NotDismissable: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ModalRoot
isOpen={isOpen}
onOpenChange={setIsOpen}
isDismissable={false}
isKeyboardDismissDisabled={true}
>
<ModalHeader hasCloseButton={false}> </ModalHeader>
<ModalBody>
<p className="text-sm"> ESC로 .</p>
</ModalBody>
<ModalFooter>
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
</Button>
</ModalFooter>
</ModalRoot>
</>
)
},
}
export const ConfirmDialog: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button color="primary" onClick={() => setIsOpen(true)}></Button>
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen} className="w-75">
<ModalBody className="px-7.5 py-8">
<p className="text-sm text-dabeeo-black-34 font-medium text-center">
?
</p>
</ModalBody>
<ModalFooter>
<Button color="gray" size="large" onClick={() => setIsOpen(false)}>
</Button>
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
</Button>
</ModalFooter>
</ModalRoot>
</>
)
},
}

View File

@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Pagination } from './Pagination'
const meta = {
title: 'Components/Pagination',
component: Pagination,
argTypes: {
totalPages: { control: 'number' },
currentPage: { control: 'number' },
pageCount: { control: 'number' },
},
} satisfies Meta<typeof Pagination>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
totalPages: 100,
currentPage: 0,
pageCount: 10,
onPageChange: () => {},
},
}
export const MiddlePage: Story = {
args: {
totalPages: 100,
currentPage: 45,
pageCount: 10,
onPageChange: () => {},
},
}
export const LastPage: Story = {
args: {
totalPages: 100,
currentPage: 99,
pageCount: 10,
onPageChange: () => {},
},
}
export const FewPages: Story = {
args: {
totalPages: 5,
currentPage: 0,
pageCount: 10,
onPageChange: () => {},
},
}
export const SinglePage: Story = {
args: {
totalPages: 1,
currentPage: 0,
pageCount: 10,
onPageChange: () => {},
},
}
export const Interactive: Story = {
render: () => {
const [currentPage, setCurrentPage] = useState(0)
return (
<div className="flex flex-col gap-4 items-center">
<Pagination
totalPages={50}
currentPage={currentPage}
pageCount={10}
onPageChange={setCurrentPage}
/>
<p className="text-sm text-dabeeo-gray-44">
: {currentPage + 1} / 50
</p>
</div>
)
},
}
export const CustomPageCount: Story = {
args: {
totalPages: 100,
currentPage: 0,
pageCount: 5,
onPageChange: () => {},
},
}

View File

@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Select, type SelectOption } from './Select'
const meta = {
title: 'Components/Select',
component: Select,
argTypes: {
isDisabled: { control: 'boolean' },
isReadOnly: { control: 'boolean' },
placement: {
control: 'select',
options: ['bottom', 'top'],
},
},
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
const sampleItems: SelectOption[] = [
{ label: '옵션 1', value: '1' },
{ label: '옵션 2', value: '2' },
{ label: '옵션 3', value: '3' },
{ label: '비활성화 옵션', value: '4', isDisabled: true },
]
export const Default: Story = {
args: {
items: sampleItems,
placeholder: '선택해주세요',
},
}
export const WithSelectedValue: Story = {
args: {
items: sampleItems,
defaultSelectedKey: '2',
},
}
export const Disabled: Story = {
args: {
items: sampleItems,
defaultSelectedKey: '1',
isDisabled: true,
},
}
export const ReadOnly: Story = {
args: {
items: sampleItems,
defaultSelectedKey: '1',
isReadOnly: true,
},
}
export const PlacementTop: Story = {
args: {
items: sampleItems,
placeholder: '위로 열림',
placement: 'top',
},
decorators: [
(Story) => (
<div className="pt-40">
<Story />
</div>
),
],
}
export const Controlled: Story = {
render: function Render() {
const [value, setValue] = useState<string | number>('1')
return (
<div className="flex flex-col gap-4">
<Select
items={sampleItems}
selectedKey={value}
onChange={setValue}
/>
<p className="text-sm"> : {value}</p>
</div>
)
},
}
export const ManyOptions: Story = {
args: {
items: Array.from({ length: 20 }, (_, i) => ({
label: `옵션 ${i + 1}`,
value: String(i + 1),
})),
placeholder: '많은 옵션',
maxHeight: 200,
},
}

View File

@@ -0,0 +1,265 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Table } from './Table'
const meta = {
title: 'Components/Table',
component: Table,
args: {
children: null
},
argTypes: {
isLayoutFixed: { control: 'boolean' },
isFullHeight: { control: 'boolean' },
isHoverable: { control: 'boolean' },
isClickable: { control: 'boolean' },
},
} satisfies Meta<typeof Table>
export default meta
type Story = StoryObj<typeof meta>
const sampleData = [
{ id: 1, name: '홍길동', email: 'hong@example.com', role: '관리자' },
{ id: 2, name: '김철수', email: 'kim@example.com', role: '사용자' },
{ id: 3, name: '이영희', email: 'lee@example.com', role: '사용자' },
{ id: 4, name: '박민수', email: 'park@example.com', role: '편집자' },
{ id: 5, name: '최지은', email: 'choi@example.com', role: '사용자' },
]
export const Default: Story = {
render: () => (
<div className="h-80">
<Table>
<Table.Caption>
<Table.CaptionLeft>
<Table.Total count={sampleData.length} />
</Table.CaptionLeft>
</Table.Caption>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={120} />
<Table.Col />
<Table.Col width={100} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">ID</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{sampleData.map((row) => (
<Table.Row key={row.id}>
<Table.Cell align="center">{row.id}</Table.Cell>
<Table.Cell>{row.name}</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell align="center">{row.role}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Container>
</Table>
</div>
),
}
export const Empty: Story = {
render: () => (
<div className="h-60">
<Table>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={120} />
<Table.Col />
<Table.Col width={100} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">ID</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
<Table.Empty colSpan={4} />
</Table.Body>
</Table.Container>
</Table>
</div>
),
}
export const Loading: Story = {
render: () => (
<div className="h-60">
<Table>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={120} />
<Table.Col />
<Table.Col width={100} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">ID</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
<Table.Loading colSpan={4} />
</Table.Body>
</Table.Container>
</Table>
</div>
),
}
export const Selectable: Story = {
render: function Render() {
const [selectedId, setSelectedId] = useState<number | null>(null)
return (
<div className="h-80">
<Table>
<Table.Caption>
<Table.CaptionLeft>
<Table.Total count={sampleData.length} />
{selectedId && (
<span className="text-sm text-primary">: {selectedId}</span>
)}
</Table.CaptionLeft>
</Table.Caption>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={120} />
<Table.Col />
<Table.Col width={100} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">ID</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{sampleData.map((row) => (
<Table.Row
key={row.id}
isSelected={selectedId === row.id}
onClick={() => setSelectedId(row.id)}
>
<Table.Cell align="center">{row.id}</Table.Cell>
<Table.Cell>{row.name}</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell align="center">{row.role}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Container>
</Table>
</div>
)
},
}
export const GroupedHeader: Story = {
render: () => (
<div className="h-80">
<Table>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={120} />
<Table.Col />
<Table.Col />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center" rowSpan={2}>ID</Table.HeaderCell>
<Table.HeaderCell rowSpan={2}></Table.HeaderCell>
<Table.HeaderCell align="center" colSpan={2} isGroupTitle></Table.HeaderCell>
</Table.HeaderRow>
<Table.HeaderRow>
<Table.HeaderCell isGroupChild></Table.HeaderCell>
<Table.HeaderCell isGroupChild></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell align="center">1</Table.Cell>
<Table.Cell></Table.Cell>
<Table.Cell>hong@example.com</Table.Cell>
<Table.Cell>010-1234-5678</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell align="center">2</Table.Cell>
<Table.Cell></Table.Cell>
<Table.Cell>kim@example.com</Table.Cell>
<Table.Cell>010-9876-5432</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Container>
</Table>
</div>
),
}
export const ManyRows: Story = {
render: () => {
const manyData = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `사용자 ${i + 1}`,
email: `user${i + 1}@example.com`,
role: i % 3 === 0 ? '관리자' : '사용자',
}))
return (
<div className="h-96">
<Table>
<Table.Caption>
<Table.CaptionLeft>
<Table.Total count={manyData.length} />
</Table.CaptionLeft>
</Table.Caption>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={120} />
<Table.Col />
<Table.Col width={100} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">ID</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{manyData.map((row) => (
<Table.Row key={row.id}>
<Table.Cell align="center">{row.id}</Table.Cell>
<Table.Cell>{row.name}</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell align="center">{row.role}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Container>
</Table>
</div>
)
},
}