Merge pull request 'feat: storybook 생성' (#9) from feature/lucy-storybook into develop
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
/api-app/app/src/main/resources/application-local.yml
|
/api-app/app/src/main/resources/application-local.yml
|
||||||
|
api-app/.gradle
|
||||||
|
api-app/build
|
||||||
19
web-app/.storybook/main.ts
Normal file
19
web-app/.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../app/**/*.stories.@(ts|tsx)'],
|
||||||
|
|
||||||
|
framework: '@storybook/react-vite',
|
||||||
|
|
||||||
|
// Storybook 전용 Vite 설정 파일 사용 (React Router 플러그인 제외)
|
||||||
|
core: {
|
||||||
|
builder: {
|
||||||
|
name: '@storybook/builder-vite',
|
||||||
|
options: {
|
||||||
|
viteConfigPath: '.storybook/vite.config.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
16
web-app/.storybook/preview.ts
Normal file
16
web-app/.storybook/preview.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Preview } from '@storybook/react'
|
||||||
|
|
||||||
|
import '../app/app.css'
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
12
web-app/.storybook/vite.config.ts
Normal file
12
web-app/.storybook/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// Storybook 전용 Vite 설정 (React Router 플러그인 제외)
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': new URL('../app', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -54,6 +54,13 @@
|
|||||||
--color-dabeeo-navy-tertiary01: #d4dde9;
|
--color-dabeeo-navy-tertiary01: #d4dde9;
|
||||||
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
||||||
|
|
||||||
|
/* Green */
|
||||||
|
--color-dabeeo-green-main: #1b8466;
|
||||||
|
--color-dabeeo-green-secondary: #125a46;
|
||||||
|
--color-dabeeo-green-tertiary: #89bea7;
|
||||||
|
--color-dabeeo-green-tertiary01: #d1e6e1;
|
||||||
|
--color-dabeeo-green-tertiary02: #ebf2f0;
|
||||||
|
|
||||||
/* Orange */
|
/* Orange */
|
||||||
--color-dabeeo-orange-main: #ff7937;
|
--color-dabeeo-orange-main: #ff7937;
|
||||||
--color-dabeeo-orange-secondary: #c14b11;
|
--color-dabeeo-orange-secondary: #c14b11;
|
||||||
|
|||||||
113
web-app/app/shared/components/button/Button.stories.tsx
Normal file
113
web-app/app/shared/components/button/Button.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import type { InputProps as AriaInputProps } from 'react-aria-components';
|
|||||||
import { Input as AriaInput } from 'react-aria-components';
|
import { Input as AriaInput } from 'react-aria-components';
|
||||||
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
import style from './style';
|
import style from './style';
|
||||||
|
|
||||||
export interface InputProps extends AriaInputProps {
|
export interface InputProps extends AriaInputProps {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
|
}
|
||||||
35
web-app/app/shared/components/inputGroup/style.ts
Normal file
35
web-app/app/shared/components/inputGroup/style.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
142
web-app/app/shared/components/modal/Modal.stories.tsx
Normal file
142
web-app/app/shared/components/modal/Modal.stories.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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: () => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
98
web-app/app/shared/components/select/Select.stories.tsx
Normal file
98
web-app/app/shared/components/select/Select.stories.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
265
web-app/app/shared/components/table/Table.stories.tsx
Normal file
265
web-app/app/shared/components/table/Table.stories.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"start": "react-router-serve ./build/server/index.js",
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsc",
|
"typecheck": "react-router typegen && tsc",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix"
|
"lint:fix": "eslint . --fix",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-router/node": "7.14.0",
|
"@react-router/node": "7.14.0",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router": "7.14.0",
|
"react-router": "7.14.0",
|
||||||
"react-stately": "^3.45.0",
|
"react-stately": "^3.45.0",
|
||||||
|
"storybook": "^10.3.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -32,6 +35,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@react-router/dev": "7.14.0",
|
"@react-router/dev": "7.14.0",
|
||||||
|
"@storybook/react": "^10.3.5",
|
||||||
|
"@storybook/react-vite": "^10.3.5",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
|
|||||||
784
web-app/pnpm-lock.yaml
generated
784
web-app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user