feature/lucy-components #10

Merged
lucy merged 2 commits from feature/lucy-components into develop 2026-04-14 10:40:15 +09:00
35 changed files with 2183 additions and 7 deletions

View File

@@ -1,9 +1,10 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// Storybook 전용 Vite 설정 (React Router 플러그인 제외)
export default defineConfig({
plugins: [tailwindcss()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'~': new URL('../app', import.meta.url).pathname,

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react'
import Breadcrumbs from './Breadcrumbs'
const meta = {
title: 'Components/Breadcrumbs',
component: Breadcrumbs,
} satisfies Meta<typeof Breadcrumbs>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
items: ['항공영상 관리', '항공영상 목록'],
},
}
export const SingleItem: Story = {
args: {
items: ['대시보드'],
},
}
export const ThreeItems: Story = {
args: {
items: ['설정', '시스템 설정', '일반'],
},
}
export const FourItems: Story = {
args: {
items: ['프로젝트', '프로젝트 A', '탐지 관리', '탐지 결과'],
},
}
export const LongNames: Story = {
args: {
items: ['항공영상 관리 시스템', '상세 항공영상 목록 페이지', '결과 분석'],
},
}

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react';
import { Breadcrumb as AriaBreadcrumb, Breadcrumbs as AriaBreadcrumbs } from 'react-aria-components';
export interface BreadcrumbsProps {
items: string[];
}
const Breadcrumbs: FC<BreadcrumbsProps> = (props) => {
const { items } = props;
return (
<nav aria-label="Breadcrumb">
<AriaBreadcrumbs className="flex items-center gap-2.5">
<AriaBreadcrumb aria-label="홈">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="19"
height="18"
viewBox="0 0 19 18"
fill="none"
>
<path
d="M6.75 14.4545H4.25V7.83333L9.25 4L14.25 7.83333V14.4545H11.75"
stroke="#6C7789"
strokeLinecap="square"
/>
<path d="M10.8381 12.9615V10.3633H7.65625V12.9615" stroke="#6C7789" />
</svg>
</AriaBreadcrumb>
{items.map((item, index) => (
<AriaBreadcrumb
className="flex items-center gap-2.5 text-xs text-dabeeo-black-47 data-current:text-dabeeo-black-22"
key={`breadcrumb-${index}-${item}`}
>
<>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="6"
height="8"
viewBox="0 0 6 8"
fill="none"
>
<path
d="M2.70722 4.00064L0.25 1.54349L1.4556 0.337891L5.12572 4.00064L1.45572 7.66347L0.250117 6.45786L2.70722 4.00064Z"
fill="#6C7789"
/>
</svg>
{item}
</>
</AriaBreadcrumb>
))}
</AriaBreadcrumbs>
</nav>
);
};
export default Breadcrumbs;

View File

@@ -0,0 +1,5 @@
import type { BreadcrumbsProps } from './Breadcrumbs';
import InternalBreadcrumbs from './Breadcrumbs';
export type { BreadcrumbsProps };
export const Breadcrumbs = InternalBreadcrumbs;

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Calendar } from './Calendar'
import { RangeCalendar } from './RangeCalendar'
const meta = {
title: 'Components/Calendar',
component: Calendar,
argTypes: {
isDisabled: {
control: 'boolean',
},
},
} satisfies Meta<typeof Calendar>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const WithSelectedDate: Story = {
render: function Render() {
const [date, setDate] = useState<Date | null>(new Date())
return <Calendar value={date} onChange={setDate} />
},
}
export const WithMinMaxDate: Story = {
render: function Render() {
const [date, setDate] = useState<Date | null>(new Date())
const minDate = new Date()
minDate.setDate(minDate.getDate() - 7)
const maxDate = new Date()
maxDate.setDate(maxDate.getDate() + 7)
return <Calendar value={date} onChange={setDate} minValue={minDate} maxValue={maxDate} />
},
}
export const Disabled: Story = {
args: {
isDisabled: true,
},
}
export const Range: StoryObj<typeof RangeCalendar> = {
render: function Render() {
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null)
return <RangeCalendar value={range} onChange={setRange} />
},
}
export const RangeWithSelectedDates: StoryObj<typeof RangeCalendar> = {
render: function Render() {
const start = new Date()
const end = new Date()
end.setDate(end.getDate() + 7)
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
return <RangeCalendar value={range} onChange={setRange} />
},
}

View File

@@ -12,7 +12,7 @@ import {
import { getLocalTimeZone, today } from '@internationalized/date';
import { tv } from 'tailwind-variants';
import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons';
import { ChevronLeftIcon, ChevronRightIcon } from '../icons';
import { calendarDateToDate, dateToCalendarDate } from './utils';
@@ -128,8 +128,7 @@ export function Calendar({ value, defaultValue, onChange, maxValue, minValue, is
isToday: !isSelected ? isToday : false,
isHovered: !isSelected ? isHovered : false,
isFocusVisible,
})
}
})}
/>
);
}}

View File

@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Checkbox } from './Checkbox'
const meta = {
title: 'Components/Checkbox',
component: Checkbox,
argTypes: {
isDisabled: {
control: 'boolean',
},
isIndeterminate: {
control: 'boolean',
},
},
} satisfies Meta<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: '동의합니다',
},
}
export const Checked: Story = {
render: function Render() {
const [isSelected, setIsSelected] = useState(true)
return (
<Checkbox isSelected={isSelected} onChange={setIsSelected}>
</Checkbox>
)
},
}
export const Unchecked: Story = {
render: function Render() {
const [isSelected, setIsSelected] = useState(false)
return (
<Checkbox isSelected={isSelected} onChange={setIsSelected}>
</Checkbox>
)
},
}
export const Indeterminate: Story = {
args: {
isIndeterminate: true,
children: '일부 선택됨',
},
}
export const Disabled: Story = {
args: {
isDisabled: true,
children: '비활성화',
},
}
export const DisabledChecked: Story = {
args: {
isDisabled: true,
isSelected: true,
children: '비활성화 (선택됨)',
},
}
export const WithoutLabel: Story = {
args: {},
}
export const MultipleCheckboxes: Story = {
render: function Render() {
const [checkedItems, setCheckedItems] = useState<Record<string, boolean>>({
option1: false,
option2: true,
option3: false,
})
const handleChange = (key: string) => (isSelected: boolean) => {
setCheckedItems((prev) => ({ ...prev, [key]: isSelected }))
}
return (
<div className="flex flex-col gap-2">
<Checkbox isSelected={checkedItems.option1} onChange={handleChange('option1')}>
1
</Checkbox>
<Checkbox isSelected={checkedItems.option2} onChange={handleChange('option2')}>
2
</Checkbox>
<Checkbox isSelected={checkedItems.option3} onChange={handleChange('option3')}>
3
</Checkbox>
</div>
)
},
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-2">
<Checkbox></Checkbox>
<Checkbox isSelected></Checkbox>
<Checkbox isIndeterminate> </Checkbox>
<Checkbox isDisabled></Checkbox>
<Checkbox isDisabled isSelected>
()
</Checkbox>
</div>
),
}

View File

@@ -0,0 +1,66 @@
import type { CheckboxProps as AriaCheckboxProps } from 'react-aria-components';
import { Checkbox as AriaCheckbox, composeRenderProps } from 'react-aria-components';
import { tv } from 'tailwind-variants';
const checkbox = tv({
base: 'inline-flex items-center gap-2 text-sm font-medium leading-7',
variants: {
isDisabled: {
true: 'text-dabeeo-gray-be',
},
},
});
const box = tv({
base: 'w-4 h-4 box-border shrink-0 flex items-center justify-center text-white border border-dabeeo-gray-be transition',
variants: {
isSelected: {
true: 'bg-primary border-primary',
},
isDisabled: {
true: 'text-dabeeo-gray-99 bg-dabeeo-gray-eb border-dabeeo-gray-be cursor-not-allowed',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
compoundVariants: [],
});
export const Checkbox = (props: AriaCheckboxProps) => {
const { className, children, ...restProps } = props;
return (
<AriaCheckbox
className={composeRenderProps(className, (className, renderProps) => checkbox({ ...renderProps, className }))}
{...restProps}
>
{composeRenderProps(children, (children, { isSelected, isIndeterminate, ...renderProps }) => (
<>
<div className={box({ isSelected: isSelected || isIndeterminate, ...renderProps })}>
{isIndeterminate ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
</svg>
) : isSelected ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7.00893L7.85714 10.625L13 5" stroke="currentColor" strokeWidth="2" />
</svg>
) : null}
</div>
{children}
</>
))}
</AriaCheckbox>
);
};

View File

@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { DatePicker } from './DatePicker'
import { DateRangePicker } from './DateRangePicker'
const meta = {
title: 'Components/DatePicker',
component: DatePicker,
args: {
value: null,
onChange: () => {},
},
argTypes: {
isDisabled: {
control: 'boolean',
},
},
} satisfies Meta<typeof DatePicker>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: function Render() {
const [date, setDate] = useState<Date | null>(null)
return <DatePicker value={date} onChange={setDate} />
},
}
export const WithSelectedDate: Story = {
render: function Render() {
const [date, setDate] = useState<Date | null>(new Date())
return <DatePicker value={date} onChange={setDate} />
},
}
export const WithMinMaxDate: Story = {
render: function Render() {
const [date, setDate] = useState<Date | null>(new Date())
const minDate = new Date()
minDate.setDate(minDate.getDate() - 7)
const maxDate = new Date()
maxDate.setDate(maxDate.getDate() + 7)
return <DatePicker value={date} onChange={setDate} minValue={minDate} maxValue={maxDate} />
},
}
export const Disabled: Story = {
render: function Render() {
const [date, setDate] = useState<Date | null>(new Date())
return <DatePicker value={date} onChange={setDate} isDisabled />
},
}
export const Range: StoryObj<typeof DateRangePicker> = {
render: function Render() {
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null)
return <DateRangePicker value={range} onChange={setRange} />
},
}
export const RangeWithSelectedDates: StoryObj<typeof DateRangePicker> = {
render: function Render() {
const start = new Date()
const end = new Date()
end.setDate(end.getDate() + 7)
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
return <DateRangePicker value={range} onChange={setRange} />
},
}
export const RangeDisabled: StoryObj<typeof DateRangePicker> = {
render: function Render() {
const start = new Date()
const end = new Date()
end.setDate(end.getDate() + 7)
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
return <DateRangePicker value={range} onChange={setRange} isDisabled />
},
}

View File

@@ -10,7 +10,7 @@ import {
import dayjs from 'dayjs';
import { tv } from 'tailwind-variants';
import { CalendarIcon } from '@/components/icons';
import { CalendarIcon } from '../icons';
import { RangeCalendar } from '../calendar/RangeCalendar';
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Descriptions } from './Descriptions'
const meta = {
title: 'Components/Descriptions',
component: Descriptions,
argTypes: {
column: {
control: 'select',
options: [1, 2, 3, 4],
},
},
} satisfies Meta<typeof Descriptions>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
items: [
{ title: '이름', content: '홍길동' },
{ title: '이메일', content: 'hong@example.com' },
{ title: '전화번호', content: '010-1234-5678' },
],
},
}
export const TwoColumns: Story = {
args: {
column: 2,
items: [
{ title: '이름', content: '홍길동' },
{ title: '이메일', content: 'hong@example.com' },
{ title: '전화번호', content: '010-1234-5678' },
{ title: '주소', content: '서울특별시 강남구' },
],
},
}
export const ThreeColumns: Story = {
args: {
column: 3,
items: [
{ title: '이름', content: '홍길동' },
{ title: '이메일', content: 'hong@example.com' },
{ title: '전화번호', content: '010-1234-5678' },
{ title: '주소', content: '서울특별시 강남구' },
{ title: '부서', content: '개발팀' },
{ title: '직급', content: '선임연구원' },
],
},
}
export const FourColumns: Story = {
args: {
column: 4,
items: [
{ title: '이름', content: '홍길동' },
{ title: '이메일', content: 'hong@example.com' },
{ title: '전화번호', content: '010-1234-5678' },
{ title: '주소', content: '서울특별시 강남구' },
],
},
}
export const WithSpan: Story = {
args: {
column: 2,
items: [
{ title: '이름', content: '홍길동' },
{ title: '이메일', content: 'hong@example.com' },
{ title: '주소', content: '서울특별시 강남구 역삼동 123-456', span: 'filled' },
],
},
}
export const WithCustomTitleWidth: Story = {
args: {
column: 2,
titleWidth: 150,
items: [
{ title: '사용자 이름', content: '홍길동' },
{ title: '이메일 주소', content: 'hong@example.com' },
{ title: '휴대전화 번호', content: '010-1234-5678' },
{ title: '거주지 주소', content: '서울특별시 강남구' },
],
},
}

View File

@@ -26,7 +26,7 @@ export const DescriptionsItem = (props: DescriptionsItemProps) => {
</div>
<div
className={clsx(
'relative flex flex-1 items-center pl-4 pr-3 py-2 text-sm font-medium text-kc-black-22 overflow-x-auto after:content-[\'\'] after:absolute after:left-0 after:right-0 after:bottom-0 after:h-px after:bg-primary-tertiary01',
'relative flex flex-1 items-center pl-4 pr-3 py-2 text-sm font-medium text-dabeeo-black-22 overflow-x-auto after:content-[\'\'] after:absolute after:left-0 after:right-0 after:bottom-0 after:h-px after:bg-primary-tertiary01',
contentClassName,
isFirstRow &&
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'

View File

@@ -0,0 +1,9 @@
import { SVGProps } from 'react';
export function OverlayArrowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M4.5 7L0 0H9L4.5 7Z" />
</svg>
);
}

View File

@@ -2,4 +2,5 @@ export * from './Calendar';
export * from './ChevronLeft';
export * from './ChevronRight';
export * from './LoadingSpinner';
export * from './OverlayArrow';

View File

@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Menu, type MenuItemChildrenType } from './Menu'
const meta = {
title: 'Components/Menu',
component: Menu,
args: {
items: [],
currentPath: '',
onSelectionChange: () => {},
},
} satisfies Meta<typeof Menu>
export default meta
type Story = StoryObj<typeof meta>
const sampleItems = [
{
id: '1',
name: '항공영상 관리',
menuUrl: null,
children: [
{ id: '1-1', name: '항공영상 목록', menuUrl: '/imagery/list' },
{ id: '1-2', name: '항공영상 등록', menuUrl: '/imagery/register' },
],
},
{
id: '2',
name: '탐지 관리',
menuUrl: null,
children: [
{ id: '2-1', name: '탐지 실행', menuUrl: '/detection/run' },
{ id: '2-2', name: '탐지 결과', menuUrl: '/detection/result' },
{ id: '2-3', name: '탐지 이력', menuUrl: '/detection/history' },
],
},
{
id: '3',
name: '설정',
menuUrl: null,
children: [
{ id: '3-1', name: '시스템 설정', menuUrl: '/settings/system' },
{ id: '3-2', name: '사용자 설정', menuUrl: '/settings/user' },
],
},
]
export const Default: Story = {
render: function Render() {
const [currentPath, setCurrentPath] = useState('/imagery/list')
const handleSelectionChange = (menu: MenuItemChildrenType) => {
setCurrentPath(menu.menuUrl)
}
return (
<div className="w-60 bg-dabeeo-gray-f5">
<Menu items={sampleItems} currentPath={currentPath} onSelectionChange={handleSelectionChange} />
</div>
)
},
}
export const WithDifferentPath: Story = {
render: function Render() {
const [currentPath, setCurrentPath] = useState('/detection/result')
const handleSelectionChange = (menu: MenuItemChildrenType) => {
setCurrentPath(menu.menuUrl)
}
return (
<div className="w-60 bg-dabeeo-gray-f5">
<Menu items={sampleItems} currentPath={currentPath} onSelectionChange={handleSelectionChange} />
</div>
)
},
}
export const SingleCategory: Story = {
render: function Render() {
const singleCategoryItems = [
{
id: '1',
name: '항공영상 관리',
menuUrl: null,
children: [
{ id: '1-1', name: '항공영상 목록', menuUrl: '/imagery/list' },
{ id: '1-2', name: '항공영상 등록', menuUrl: '/imagery/register' },
{ id: '1-3', name: '항공영상 상세', menuUrl: '/imagery/detail' },
],
},
]
const [currentPath, setCurrentPath] = useState('/imagery/list')
const handleSelectionChange = (menu: MenuItemChildrenType) => {
setCurrentPath(menu.menuUrl)
}
return (
<div className="w-60 bg-dabeeo-gray-f5">
<Menu items={singleCategoryItems} currentPath={currentPath} onSelectionChange={handleSelectionChange} />
</div>
)
},
}

View File

@@ -0,0 +1,154 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Button } from '../button/Button'
import { AlertModal } from './AlertModal'
import { ConfirmModal } from './ConfirmModal'
const meta = {
title: 'Components/Modal/AlertModal',
component: AlertModal,
} satisfies Meta<typeof AlertModal>
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>
<AlertModal
isOpen={isOpen}
onOpenChange={setIsOpen}
title="알림"
content="작업이 완료되었습니다."
onConfirm={() => console.log('확인 클릭')}
/>
</>
)
},
}
export const WithoutTitle: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<AlertModal
isOpen={isOpen}
onOpenChange={setIsOpen}
content="제목 없는 알림 메시지입니다."
onConfirm={() => console.log('확인 클릭')}
/>
</>
)
},
}
export const CustomConfirmText: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<AlertModal
isOpen={isOpen}
onOpenChange={setIsOpen}
title="알림"
content="파일이 성공적으로 업로드되었습니다."
confirmText="닫기"
onConfirm={() => console.log('닫기 클릭')}
/>
</>
)
},
}
export const MultilineContent: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<AlertModal
isOpen={isOpen}
onOpenChange={setIsOpen}
title="안내"
content={`처리가 완료되었습니다.\n\n총 3개의 파일이 업로드되었습니다.\n업로드된 파일은 목록에서 확인하실 수 있습니다.`}
onConfirm={() => console.log('확인 클릭')}
/>
</>
)
},
}
const confirmMeta = {
title: 'Components/Modal/ConfirmModal',
component: ConfirmModal,
} satisfies Meta<typeof ConfirmModal>
export const Confirm: StoryObj<typeof ConfirmModal> = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ConfirmModal
isOpen={isOpen}
onOpenChange={setIsOpen}
title="확인"
content="정말 삭제하시겠습니까?"
onConfirm={() => console.log('삭제 확인')}
onCancel={() => console.log('취소 클릭')}
/>
</>
)
},
}
export const ConfirmWithCustomText: StoryObj<typeof ConfirmModal> = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ConfirmModal
isOpen={isOpen}
onOpenChange={setIsOpen}
title="저장 확인"
content="변경사항을 저장하시겠습니까?"
confirmText="저장"
cancelText="저장 안 함"
onConfirm={() => console.log('저장 확인')}
onCancel={() => console.log('저장 안 함 클릭')}
/>
</>
)
},
}
export const ConfirmWithAsyncAction: StoryObj<typeof ConfirmModal> = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false)
const handleConfirm = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000))
console.log('비동기 작업 완료')
}
return (
<>
<Button onClick={() => setIsOpen(true)}> </Button>
<ConfirmModal
isOpen={isOpen}
onOpenChange={setIsOpen}
title="삭제 확인"
content="삭제하면 복구할 수 없습니다. 계속하시겠습니까?"
confirmText="삭제"
cancelText="취소"
onConfirm={handleConfirm}
/>
</>
)
},
}

View File

@@ -5,6 +5,12 @@ import { Pagination } from './Pagination'
const meta = {
title: 'Components/Pagination',
component: Pagination,
args: {
totalPages: 100,
currentPage: 0,
pageCount: 10,
onPageChange: () => {},
},
argTypes: {
totalPages: { control: 'number' },
currentPage: { control: 'number' },
@@ -61,7 +67,7 @@ export const SinglePage: Story = {
}
export const Interactive: Story = {
render: () => {
render: function Render() {
const [currentPage, setCurrentPage] = useState(0)
return (
<div className="flex flex-col gap-4 items-center">

View File

@@ -0,0 +1,126 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Radio } from './Radio'
const meta = {
title: 'Components/Radio',
component: Radio,
args: {
name: 'default',
value: 'option1',
},
argTypes: {
disabled: {
control: 'boolean',
},
readOnly: {
control: 'boolean',
},
},
} satisfies Meta<typeof Radio>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
name: 'default',
value: 'option1',
children: '옵션 1',
},
}
export const Checked: Story = {
args: {
name: 'checked',
value: 'option1',
checked: true,
children: '선택됨',
},
}
export const Disabled: Story = {
args: {
name: 'disabled',
value: 'option1',
disabled: true,
children: '비활성화',
},
}
export const DisabledChecked: Story = {
args: {
name: 'disabled-checked',
value: 'option1',
disabled: true,
checked: true,
children: '비활성화 (선택됨)',
},
}
export const ReadOnly: Story = {
args: {
name: 'readonly',
value: 'option1',
readOnly: true,
checked: true,
children: '읽기 전용',
},
}
export const RadioGroup: Story = {
render: () => {
const [selected, setSelected] = useState('option1')
return (
<div className="flex flex-col gap-2">
<Radio name="group" value="option1" checked={selected === 'option1'} onChange={setSelected}>
1
</Radio>
<Radio name="group" value="option2" checked={selected === 'option2'} onChange={setSelected}>
2
</Radio>
<Radio name="group" value="option3" checked={selected === 'option3'} onChange={setSelected}>
3
</Radio>
</div>
)
},
}
export const HorizontalRadioGroup: Story = {
render: () => {
const [selected, setSelected] = useState('option1')
return (
<div className="flex gap-4">
<Radio name="horizontal-group" value="option1" checked={selected === 'option1'} onChange={setSelected}>
1
</Radio>
<Radio name="horizontal-group" value="option2" checked={selected === 'option2'} onChange={setSelected}>
2
</Radio>
<Radio name="horizontal-group" value="option3" checked={selected === 'option3'} onChange={setSelected}>
3
</Radio>
</div>
)
},
}
export const WithMixedStates: Story = {
render: () => {
const [selected, setSelected] = useState('option1')
return (
<div className="flex flex-col gap-2">
<Radio name="mixed" value="option1" checked={selected === 'option1'} onChange={setSelected}>
1
</Radio>
<Radio name="mixed" value="option2" checked={selected === 'option2'} onChange={setSelected}>
2
</Radio>
<Radio name="mixed" value="option3" disabled>
</Radio>
</div>
)
},
}

View File

@@ -0,0 +1,102 @@
import { type ReactNode, useId } from 'react';
import { tv } from 'tailwind-variants';
export interface RadioProps {
name: string;
value: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (value: string) => void;
disabled?: boolean;
readOnly?: boolean;
children?: ReactNode;
className?: string;
}
const radioStyles = tv({
base: [
'shrink-0 w-4 h-4 min-w-4 min-h-4 box-border rounded-full',
'border-2 border-dabeeo-gray-be bg-white',
'flex items-center justify-center',
'transition-all duration-200',
'peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-primary',
],
variants: {
isSelected: {
true: 'border-primary bg-primary',
},
isDisabled: {
true: 'border-dabeeo-gray-be bg-dabeeo-gray-eb',
},
isReadOnly: {
true: '',
},
},
compoundVariants: [
{
isSelected: true,
isDisabled: true,
className: 'border-dabeeo-gray-be bg-dabeeo-gray-eb',
},
],
});
const innerCircleStyles = tv({
base: 'w-2 h-2 rounded-full transition-all duration-200',
variants: {
isSelected: {
true: 'bg-white',
false: 'bg-transparent',
},
isDisabled: {
true: 'bg-dabeeo-gray-eb',
},
},
compoundVariants: [
{
isSelected: true,
isDisabled: true,
className: 'bg-dabeeo-gray-eb',
},
],
});
const labelStyles = tv({
base: 'text-dabeeo-black-22 text-sm font-medium inline-flex items-center',
variants: {
isDisabled: {
true: 'text-dabeeo-gray-be',
},
},
});
export const Radio = (props: RadioProps) => {
const { name, value, checked, defaultChecked, onChange, disabled, readOnly, children, className } = props;
const id = useId();
const isInteractive = !disabled && !readOnly;
return (
<label
htmlFor={id}
className={`inline-flex h-9 items-center gap-2.5 ${isInteractive ? 'cursor-pointer' : 'cursor-not-allowed'} ${className ?? ''}`}
>
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
defaultChecked={defaultChecked}
onChange={(e) => isInteractive && onChange?.(e.target.value)}
disabled={disabled}
readOnly={readOnly}
className="sr-only peer"
/>
<span className={radioStyles({ isSelected: checked, isDisabled: disabled, isReadOnly: readOnly })}>
<span className={innerCircleStyles({ isSelected: checked, isDisabled: disabled })} />
</span>
{children && <span className={labelStyles({ isDisabled: disabled })}>{children}</span>}
</label>
);
};

View File

@@ -0,0 +1,122 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { RadioGroup } from './RadioGroup'
const meta = {
title: 'Components/RadioGroup',
component: RadioGroup,
args: {
items: [],
orientation: 'horizontal',
isDisabled: false,
isReadOnly: false,
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
},
isDisabled: {
control: 'boolean',
},
isReadOnly: {
control: 'boolean',
},
},
} satisfies Meta<typeof RadioGroup>
export default meta
type Story = StoryObj<typeof meta>
const defaultItems = [
{ value: 'option1', label: '옵션 1' },
{ value: 'option2', label: '옵션 2' },
{ value: 'option3', label: '옵션 3' },
]
export const Default: Story = {
args: {
items: defaultItems,
},
}
export const Horizontal: Story = {
args: {
items: defaultItems,
orientation: 'horizontal',
},
}
export const Vertical: Story = {
args: {
items: defaultItems,
orientation: 'vertical',
},
}
export const WithDefaultValue: Story = {
render: function Render() {
const [value, setValue] = useState('option2')
return (
<RadioGroup
items={defaultItems}
value={value}
onChange={setValue}
/>
)
},
}
export const Disabled: Story = {
args: {
items: defaultItems,
isDisabled: true,
},
}
export const ReadOnly: Story = {
args: {
items: defaultItems,
isReadOnly: true,
value: 'option1',
},
}
export const WithDisabledOption: Story = {
args: {
items: [
{ value: 'option1', label: '옵션 1' },
{ value: 'option2', label: '옵션 2 (비활성화)', isDisabled: true },
{ value: 'option3', label: '옵션 3' },
],
},
}
export const WithManyOptions: Story = {
args: {
items: [
{ value: 'apple', label: '사과' },
{ value: 'banana', label: '바나나' },
{ value: 'orange', label: '오렌지' },
{ value: 'grape', label: '포도' },
{ value: 'strawberry', label: '딸기' },
],
orientation: 'vertical',
},
}
export const Controlled: Story = {
render: function Render() {
const [value, setValue] = useState('')
return (
<div className="space-y-4">
<RadioGroup
items={defaultItems}
value={value}
onChange={setValue}
/>
<p className="text-sm text-gray-600"> : {value || '없음'}</p>
</div>
)
},
}

View File

@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { useId } from 'react';
import type { RadioGroupProps as AriaRadioGroupProps, RadioProps as AriaRadioProps } from 'react-aria-components';
import { Radio as AriaRadio, RadioGroup as AriaRadioGroup, composeRenderProps } from 'react-aria-components';
import clsx from 'clsx';
import { tv } from 'tailwind-variants';
export type RadioOption = {
value: string;
label: ReactNode;
isDisabled?: boolean;
};
export interface RadioGroupProps extends AriaRadioGroupProps {
items: RadioOption[];
}
export const RadioGroup = (props: RadioGroupProps) => {
const radioGroupId = useId();
const { items, className, value, ...restProps } = props;
return (
<AriaRadioGroup
className={clsx('flex gap-4 data-[orientation=vertical]:flex-col data-[orientation=vertical]:gap-3', className)}
value={value === '' ? null : value}
{...restProps}
>
{items.map((item) => {
return (
<Radio key={`radio_group-${radioGroupId}-${item.value}`} value={item.value} isDisabled={item.isDisabled}>
{item.label}
</Radio>
);
})}
</AriaRadioGroup>
);
};
interface RadioProps extends AriaRadioProps {}
const radioStyles = tv({
base: 'shrink-0 w-4 h-4 box-border rounded-full border-[1.5px] border-dabeeo-gray-be bg-white transition-all',
variants: {
isSelected: {
true: 'border-4 border-primary ',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
isInvalid: {
true: 'border-dabeeo-red',
},
isDisabled: {
true: 'border-dabeeo-gray-be',
},
},
compoundVariants: [{ isDisabled: true, isSelected: false, className: 'bg-dabeeo-gray-eb' }],
});
export const Radio = (props: RadioProps) => {
const { className, ...restProps } = props;
return (
<AriaRadio
className={clsx(
'inline-flex items-center gap-2.5 text-dabeeo-black-22 text-sm font-medium data-disabled:text-dabeeo-gray-be',
className
)}
{...restProps}
>
{composeRenderProps(props.children, (children, renderProps) => (
<>
<div className={radioStyles(renderProps)} />
{children}
</>
))}
</AriaRadio>
);
};

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Section } from './Section'
const meta = {
title: 'Components/Section',
component: Section,
args: {
variant: 'base',
children: <div>Base </div>,
},
argTypes: {
variant: {
control: 'select',
options: ['base', 'card', 'list', 'searchFilterGray'],
},
},
} satisfies Meta<typeof Section>
export default meta
type Story = StoryObj<typeof meta>
export const Base: Story = {
args: {
variant: 'base',
children: (
<div>
<h2 className="text-lg font-bold mb-2"> </h2>
<p> .</p>
</div>
),
},
}
export const Card: Story = {
args: {
variant: 'card',
children: (
<div>
<h2 className="text-lg font-bold mb-2"> </h2>
<p> .</p>
</div>
),
},
}
export const List: Story = {
args: {
variant: 'list',
children: (
<>
<h2 className="text-lg font-bold"> </h2>
<ul className="space-y-2">
<li className="p-2 bg-gray-50 rounded"> 1</li>
<li className="p-2 bg-gray-50 rounded"> 2</li>
<li className="p-2 bg-gray-50 rounded"> 3</li>
</ul>
</>
),
},
}
export const SearchFilterGray: Story = {
args: {
variant: 'searchFilterGray',
children: (
<>
<span>:</span>
<select className="border border-gray-300 rounded px-2 py-1">
<option></option>
<option> 1</option>
<option> 2</option>
</select>
<input type="text" placeholder="검색어 입력" className="border border-gray-300 rounded px-2 py-1" />
</>
),
},
}
export const AllVariants: Story = {
render: () => (
<div className="space-y-4">
<Section variant="base">
<div>Base </div>
</Section>
<Section variant="card">
<div>Card </div>
</Section>
<Section variant="list">
<div>List </div>
</Section>
<Section variant="searchFilterGray">
<span>SearchFilterGray </span>
</Section>
</div>
),
}

View File

@@ -0,0 +1,132 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Switch } from './Switch'
const meta = {
title: 'Components/Switch',
component: Switch,
argTypes: {
isDisabled: {
control: 'boolean',
},
isReadOnly: {
control: 'boolean',
},
},
} satisfies Meta<typeof Switch>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: '알림 설정',
},
}
export const Selected: Story = {
render: function Render() {
const [isSelected, setIsSelected] = useState(true)
return (
<Switch isSelected={isSelected} onChange={setIsSelected}>
</Switch>
)
},
}
export const Unselected: Story = {
render: function Render() {
const [isSelected, setIsSelected] = useState(false)
return (
<Switch isSelected={isSelected} onChange={setIsSelected}>
</Switch>
)
},
}
export const Disabled: Story = {
args: {
isDisabled: true,
children: '비활성화된 스위치',
},
}
export const DisabledSelected: Story = {
args: {
isDisabled: true,
isSelected: true,
children: '비활성화됨 (켜짐)',
},
}
export const ReadOnly: Story = {
args: {
isReadOnly: true,
isSelected: true,
children: '읽기 전용',
},
}
export const WithoutLabel: Story = {
args: {},
}
export const Controlled: Story = {
render: function Render() {
const [isSelected, setIsSelected] = useState(false)
return (
<div className="space-y-4">
<Switch isSelected={isSelected} onChange={setIsSelected}>
</Switch>
<p className="text-sm text-gray-600">: {isSelected ? '켜짐' : '꺼짐'}</p>
</div>
)
},
}
export const MultipleSettings: Story = {
render: function Render() {
const [settings, setSettings] = useState({
notifications: true,
darkMode: false,
autoSave: true,
})
const handleChange = (key: keyof typeof settings) => (isSelected: boolean) => {
setSettings((prev) => ({ ...prev, [key]: isSelected }))
}
return (
<div className="space-y-4">
<Switch isSelected={settings.notifications} onChange={handleChange('notifications')}>
</Switch>
<Switch isSelected={settings.darkMode} onChange={handleChange('darkMode')}>
</Switch>
<Switch isSelected={settings.autoSave} onChange={handleChange('autoSave')}>
</Switch>
</div>
)
},
}
export const AllStates: Story = {
render: () => (
<div className="space-y-4">
<Switch> ()</Switch>
<Switch isSelected> ()</Switch>
<Switch isDisabled> ()</Switch>
<Switch isDisabled isSelected>
()
</Switch>
<Switch isReadOnly isSelected>
</Switch>
</div>
),
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { Switch as AriaSwitch, SwitchProps as AriaSwitchProps } from 'react-aria-components';
import clsx from 'clsx';
import { tv } from 'tailwind-variants';
export interface SwitchProps extends Omit<AriaSwitchProps, 'children'> {
children?: ReactNode;
}
const track = tv({
base: 'flex h-4.5 w-8 box-border px-1 items-center rounded-full transition duration-200 ease-in-out',
variants: {
isSelected: {
false: 'bg-dabeeo-gray-99',
true: 'bg-dabeeo-green-main',
},
isDisabled: {
true: 'bg-dabeeo-gray-eb',
},
isReadOnly: {
true: 'opacity-50',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
});
const handle = tv({
base: 'h-3 w-3 transform rounded-full outline outline-1 -outline-offset-1 outline-transparent shadow-[0_1px_3px_0_rgba(5,48,48,0.35)] transition duration-200 ease-in-out',
variants: {
isSelected: {
false: 'translate-x-0 bg-white',
true: 'translate-x-[11px] bg-white',
},
},
});
export function Switch({ children, ...props }: SwitchProps) {
return (
<AriaSwitch
{...props}
className={clsx(
props.className,
'relative flex gap-2 items-center text-dabeeo-black-34 text-sm transition [-webkit-tap-highlight-color:transparent] cursor-pointer'
)}
>
{(renderProps) => (
<>
<div className={track(renderProps)}>
<span className={handle(renderProps)} />
</div>
{children}
</>
)}
</AriaSwitch>
);
}

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Tab } from './Tab'
const meta = {
title: 'Components/Tab',
component: Tab,
args: {
items: [],
},
} satisfies Meta<typeof Tab>
export default meta
type Story = StoryObj<typeof meta>
const defaultItems = [
{ key: 'tab1', label: '탭 1', children: <div className="p-4"> 1 .</div> },
{ key: 'tab2', label: '탭 2', children: <div className="p-4"> 2 .</div> },
{ key: 'tab3', label: '탭 3', children: <div className="p-4"> 3 .</div> },
]
export const Default: Story = {
args: {
items: defaultItems,
},
}
export const WithDefaultSelected: Story = {
args: {
items: defaultItems,
defaultSelectedKey: 'tab2',
},
}
export const Controlled: Story = {
render: function Render() {
const [selectedKey, setSelectedKey] = useState('tab1')
return (
<Tab
items={defaultItems}
selectedKey={selectedKey}
onSelectionChange={(key) => setSelectedKey(key as string)}
/>
)
},
}
export const WithDisabledTab: Story = {
args: {
items: [
{ key: 'tab1', label: '탭 1', children: <div className="p-4"> 1 .</div> },
{ key: 'tab2', label: '탭 2 (비활성화)', children: <div className="p-4"> 2 .</div>, disabled: true },
{ key: 'tab3', label: '탭 3', children: <div className="p-4"> 3 .</div> },
],
},
}
export const WithRichContent: Story = {
args: {
items: [
{
key: 'overview',
label: '개요',
children: (
<div className="p-4 space-y-2">
<h3 className="text-lg font-bold"> </h3>
<p> .</p>
<ul className="list-disc list-inside">
<li> </li>
<li>AI </li>
<li> </li>
</ul>
</div>
),
},
{
key: 'details',
label: '상세 정보',
children: (
<div className="p-4 space-y-2">
<h3 className="text-lg font-bold"> </h3>
<table className="w-full border-collapse">
<tbody>
<tr className="border-b">
<td className="py-2 font-medium"></td>
<td className="py-2">1.0.0</td>
</tr>
<tr className="border-b">
<td className="py-2 font-medium"> </td>
<td className="py-2">2024-01-15</td>
</tr>
</tbody>
</table>
</div>
),
},
{
key: 'settings',
label: '설정',
children: (
<div className="p-4">
<h3 className="text-lg font-bold mb-4"></h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1"> </label>
<select className="border border-gray-300 rounded px-3 py-2 w-full">
<option> </option>
<option> </option>
<option> </option>
</select>
</div>
</div>
</div>
),
},
],
},
}
export const ManyTabs: Story = {
args: {
items: [
{ key: 'tab1', label: '첫 번째', children: <div className="p-4"> </div> },
{ key: 'tab2', label: '두 번째', children: <div className="p-4"> </div> },
{ key: 'tab3', label: '세 번째', children: <div className="p-4"> </div> },
{ key: 'tab4', label: '네 번째', children: <div className="p-4"> </div> },
{ key: 'tab5', label: '다섯 번째', children: <div className="p-4"> </div> },
],
},
}
export const WithCustomClassName: Story = {
args: {
items: defaultItems,
className: 'bg-gray-50 p-4 rounded-lg',
tabPanelClassName: 'bg-white rounded border',
},
}

View File

@@ -0,0 +1,55 @@
import type { TabsProps } from 'react-aria-components';
import { Tab as AriaTab, TabList, TabPanel, TabPanels, Tabs } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
type TabItem = {
key: string;
label: React.ReactNode;
children: React.ReactNode;
disabled?: boolean;
};
// type TabProps = {
// items: TabItem[];
// activeKey?: string;
// onClick?: (key: string) => void;
// className?: string;
// };
/**
* @desc 탭 컴포넌트
* @param items 탭 아이템 목록
* @param activeKey 현재 활성화된 탭의 키
* @param onClick 탭 변경 이벤트
* @param className 추가 클래스명
*/
export interface TabProps extends Omit<TabsProps, 'className' | 'children'> {
className?: string;
items: TabItem[];
tabPanelClassName?: string;
tabPanelsClassName?: string;
}
export const Tab = ({ items, className, tabPanelClassName, tabPanelsClassName, ...restProps }: TabProps) => {
return (
<Tabs className={twMerge('flex flex-col gap-4', className)} {...restProps}>
<TabList className="flex items-center text-lg font-medium cursor-pointer" items={items}>
{(item) => (
<AriaTab
id={item.key}
className="relative px-7.5 text-dabeeo-black-47 data-[selected=true]:text-primary first:pl-1.5 data-[selected=true]:font-bold after:absolute after:right-0 after:top-1/2 after:-translate-y-1/2 after:h-[18px] after:w-px after:bg-gray-300 last:after:hidden outline-none data-focus-visible:ring-2 data-focus-visible:ring-offset-2 data-focus-visible:ring-primary"
>
{item.label}
</AriaTab>
)}
</TabList>
<TabPanels className={tabPanelsClassName} items={items}>
{(item) => (
<TabPanel id={item.key} className={tabPanelClassName}>
{item.children}
</TabPanel>
)}
</TabPanels>
</Tabs>
);
};

View File

@@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { TextArea } from './TextArea'
const meta = {
title: 'Components/TextArea',
component: TextArea,
argTypes: {
readOnly: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
},
} satisfies Meta<typeof TextArea>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
placeholder: '내용을 입력하세요',
className: 'w-80 h-32',
},
}
export const WithValue: Story = {
render: function Render() {
const [value, setValue] = useState('기본 텍스트 내용입니다.')
return (
<TextArea
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-80 h-32"
/>
)
},
}
export const ReadOnly: Story = {
args: {
value: '읽기 전용 텍스트입니다. 수정할 수 없습니다.',
readOnly: true,
className: 'w-80 h-32',
},
}
export const Disabled: Story = {
args: {
value: '비활성화된 텍스트입니다.',
disabled: true,
className: 'w-80 h-32',
},
}
export const WithPlaceholder: Story = {
args: {
placeholder: '여기에 상세 설명을 입력하세요...',
className: 'w-80 h-32',
},
}
export const LargeTextArea: Story = {
args: {
placeholder: '큰 텍스트 영역입니다.',
className: 'w-full h-48',
},
}
export const SmallTextArea: Story = {
args: {
placeholder: '작은 텍스트 영역입니다.',
className: 'w-60 h-20',
},
}
export const WithMaxLength: Story = {
render: function Render() {
const [value, setValue] = useState('')
const maxLength = 100
return (
<div className="space-y-2">
<TextArea
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={maxLength}
placeholder="최대 100자까지 입력 가능합니다."
className="w-80 h-32"
/>
<p className="text-sm text-gray-500">
{value.length}/{maxLength}
</p>
</div>
)
},
}
export const WithRows: Story = {
args: {
placeholder: 'rows 속성으로 높이를 지정할 수 있습니다.',
rows: 5,
className: 'w-80',
},
}
export const AllStates: Story = {
render: () => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<TextArea placeholder="기본 상태" className="w-80 h-24" />
</div>
<div>
<label className="block text-sm font-medium mb-1"> </label>
<TextArea value="읽기 전용 텍스트" readOnly className="w-80 h-24" />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<TextArea value="비활성화된 텍스트" disabled className="w-80 h-24" />
</div>
</div>
),
}

View File

@@ -0,0 +1,21 @@
import { TextArea as AriaTextArea, TextAreaProps as AriaTextAreaProps } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
interface TextAreaProps extends Omit<AriaTextAreaProps, 'className'> {
className?: string;
}
export const TextArea = (props: TextAreaProps) => {
const { className, ...restProps } = props;
return (
<AriaTextArea
{...restProps}
className={twMerge(
'block px-3 py-2.5 text-sm text-dabeeo-black-34 border border-dabeeo-gray-be data-[hovered=true]:border-dabeeo-black-34 data-[focused=true]:text-dabeeo-black-34 data-focused:border-dabeeo-black-34 outline-0 resize-none',
'read-only:bg-dabeeo-yellow-secondary read-only:data-[hovered=true]:border-dabeeo-gray-be read-only:data-[focused=true]:border-dabeeo-gray-be',
className
)}
/>
);
};

View File

@@ -0,0 +1 @@
export * from './TextArea';

View File

@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Button, TooltipTrigger } from 'react-aria-components'
import { Tooltip } from './Tooltip'
const meta = {
title: 'Components/Tooltip',
component: Tooltip,
args: {
children: '툴팁 내용',
},
argTypes: {
bgColor: {
control: 'select',
options: ['white', 'tertiary'],
},
},
} satisfies Meta<typeof Tooltip>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<TooltipTrigger>
<Button className="px-4 py-2 bg-primary text-white rounded"> </Button>
<Tooltip> .</Tooltip>
</TooltipTrigger>
),
}
export const WhiteBackground: Story = {
render: () => (
<TooltipTrigger>
<Button className="px-4 py-2 bg-primary text-white rounded"> </Button>
<Tooltip bgColor="white"> .</Tooltip>
</TooltipTrigger>
),
}
export const TertiaryBackground: Story = {
render: () => (
<TooltipTrigger>
<Button className="px-4 py-2 bg-primary text-white rounded">Tertiary </Button>
<Tooltip bgColor="tertiary">Tertiary .</Tooltip>
</TooltipTrigger>
),
}
export const LongContent: Story = {
render: () => (
<TooltipTrigger>
<Button className="px-4 py-2 bg-primary text-white rounded"> </Button>
<Tooltip>
. 200px로 .
.
</Tooltip>
</TooltipTrigger>
),
}
export const MultilineContent: Story = {
render: () => (
<TooltipTrigger>
<Button className="px-4 py-2 bg-primary text-white rounded"> </Button>
<Tooltip>{`첫 번째 줄\n두 번째 줄\n세 번째 줄`}</Tooltip>
</TooltipTrigger>
),
}
export const PlacementVariants: Story = {
render: () => (
<div className="flex gap-8 p-16">
<TooltipTrigger>
<Button className="px-4 py-2 bg-gray-200 rounded">Top ()</Button>
<Tooltip placement="top"> </Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button className="px-4 py-2 bg-gray-200 rounded">Bottom</Button>
<Tooltip placement="bottom"> </Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button className="px-4 py-2 bg-gray-200 rounded">Left</Button>
<Tooltip placement="left"> </Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button className="px-4 py-2 bg-gray-200 rounded">Right</Button>
<Tooltip placement="right"> </Tooltip>
</TooltipTrigger>
</div>
),
}
export const AllBackgroundColors: Story = {
render: () => (
<div className="flex gap-8 p-8">
<TooltipTrigger>
<Button className="px-4 py-2 bg-gray-200 rounded">White</Button>
<Tooltip bgColor="white">White </Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button className="px-4 py-2 bg-gray-200 rounded">Tertiary</Button>
<Tooltip bgColor="tertiary">Tertiary </Tooltip>
</TooltipTrigger>
</div>
),
}

View File

@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import {
Tooltip as AriaTooltip,
TooltipProps as AriaTooltipProps,
OverlayArrow,
composeRenderProps,
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
import { OverlayArrowIcon } from '../icons';
const style = tv({
base: 'group/tooltip px-2 py-1.5 max-w-[200px] drop-shadow-modal rounded text-sm',
variants: {
bgColor: {
white: 'bg-white [&_svg]:fill-white',
tertiary: 'bg-primary-tertiary [&_svg]:fill-primary-tertiary',
},
},
});
export interface TooltipProps extends Omit<AriaTooltipProps, 'children'> {
bgColor?: 'white' | 'tertiary';
children: ReactNode;
}
export function Tooltip({ children, ...props }: TooltipProps) {
const { className, bgColor = 'white', offset = 10, ...restProps } = props;
return (
<AriaTooltip
offset={offset}
{...restProps}
className={composeRenderProps(className, (className, renderProps) =>
style({ ...renderProps, className, bgColor })
)}
>
<OverlayArrow>
<OverlayArrowIcon className="block group-data-[placement=bottom]/tooltip:rotate-180 group-data-[placement=left]/tooltip:-rotate-90 group-data-[placement=right]/tooltip:rotate-90" />
</OverlayArrow>
<span className="block max-h-[200px] overflow-y-auto whitespace-pre-wrap">{children}</span>
</AriaTooltip>
);
}

View File

@@ -0,0 +1 @@
export * from './Tooltip';

View File

@@ -0,0 +1,156 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import type { Selection } from 'react-stately'
import { Tree, type TreeItemType } from './Tree'
const meta = {
title: 'Components/Tree',
component: Tree,
args: {
items: [],
},
argTypes: {
selectionMode: {
control: 'select',
options: ['single', 'multiple', 'none'],
},
enableDragAndDrop: {
control: 'boolean',
},
},
} satisfies Meta<typeof Tree>
export default meta
type Story = StoryObj<typeof meta>
const sampleItems: TreeItemType[] = [
{
id: 1,
title: '프로젝트 A',
type: 'directory',
children: [
{ id: 11, title: '문서 1', type: 'file' },
{ id: 12, title: '문서 2', type: 'file' },
{ id: 13, title: '문서 3', type: 'file' },
],
},
{
id: 2,
title: '프로젝트 B',
type: 'directory',
children: [
{ id: 21, title: '파일 1', type: 'file' },
{ id: 22, title: '파일 2', type: 'file' },
],
},
{
id: 3,
title: '프로젝트 C',
type: 'directory',
children: [
{ id: 31, title: '항목 1', type: 'file' },
{ id: 32, title: '항목 2', type: 'file' },
{ id: 33, title: '항목 3', type: 'file' },
{ id: 34, title: '항목 4', type: 'file' },
],
},
]
export const Default: Story = {
args: {
items: sampleItems,
selectionMode: 'single',
},
}
export const SingleSelection: Story = {
render: function Render() {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
return (
<div className="w-80 border border-gray-200">
<Tree
items={sampleItems}
selectionMode="single"
selectedKeys={selectedKeys as Set<number | string>}
onSelectionChange={setSelectedKeys}
/>
</div>
)
},
}
export const MultipleSelection: Story = {
render: function Render() {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
return (
<div className="w-80 border border-gray-200">
<Tree
items={sampleItems}
selectionMode="multiple"
selectedKeys={selectedKeys as Set<number | string>}
onSelectionChange={setSelectedKeys}
/>
</div>
)
},
}
export const WithDragAndDrop: Story = {
render: function Render() {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
return (
<div className="w-80 border border-gray-200">
<Tree
items={sampleItems}
selectionMode="single"
selectedKeys={selectedKeys as Set<number | string>}
onSelectionChange={setSelectedKeys}
enableDragAndDrop={true}
onReorder={(sourceKey, targetKey, dropPosition, parentKey) => {
console.log('Reorder:', { sourceKey, targetKey, dropPosition, parentKey })
}}
/>
</div>
)
},
}
export const WithoutDragAndDrop: Story = {
args: {
items: sampleItems,
selectionMode: 'single',
enableDragAndDrop: false,
},
}
export const NestedTree: Story = {
args: {
items: [
{
id: 1,
title: '루트 폴더',
type: 'directory',
children: [
{
id: 11,
title: '하위 폴더 1',
type: 'directory',
children: [
{ id: 111, title: '파일 A', type: 'file' },
{ id: 112, title: '파일 B', type: 'file' },
],
},
{
id: 12,
title: '하위 폴더 2',
type: 'directory',
children: [
{ id: 121, title: '파일 C', type: 'file' },
],
},
],
},
],
selectionMode: 'single',
},
}

View File

@@ -13,6 +13,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@internationalized/date": "^3.12.0",
"@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0",
"axios": "^1.15.0",
@@ -42,6 +43,7 @@
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-unused-imports": "^4.4.1",

29
web-app/pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@internationalized/date':
specifier: ^3.12.0
version: 3.12.0
'@react-router/node':
specifier: 7.14.0
version: 7.14.0(react-router@7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
@@ -90,6 +93,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
eslint:
specifier: ^10.2.0
version: 10.2.0(jiti@2.6.1)
@@ -1270,6 +1276,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.12':
resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
@@ -1688,6 +1697,19 @@ packages:
resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
babel-plugin-react-compiler: ^1.0.0
vite: ^8.0.0
peerDependenciesMeta:
'@rolldown/plugin-babel':
optional: true
babel-plugin-react-compiler:
optional: true
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
@@ -4719,6 +4741,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.12': {}
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@rollup/pluginutils@5.3.0(rollup@4.60.1)':
dependencies:
'@types/estree': 1.0.8
@@ -5127,6 +5151,11 @@ snapshots:
'@typescript-eslint/types': 8.58.0
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3