Compare commits
5 Commits
b390777af0
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 861fac88f7 | |||
| 8d6bff88d6 | |||
| 52f8c30b2e | |||
| 01c3590682 | |||
| 01bfc751f2 |
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
|
||||||
13
web-app/.storybook/vite.config.ts
Normal file
13
web-app/.storybook/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// Storybook 전용 Vite 설정 (React Router 플러그인 제외)
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), 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;
|
||||||
|
|||||||
@@ -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: ['항공영상 관리 시스템', '상세 항공영상 목록 페이지', '결과 분석'],
|
||||||
|
},
|
||||||
|
}
|
||||||
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal file
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal 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;
|
||||||
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { BreadcrumbsProps } from './Breadcrumbs';
|
||||||
|
import InternalBreadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
|
export type { BreadcrumbsProps };
|
||||||
|
export const Breadcrumbs = InternalBreadcrumbs;
|
||||||
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>
|
||||||
|
),
|
||||||
|
}
|
||||||
62
web-app/app/shared/components/calendar/Calendar.stories.tsx
Normal file
62
web-app/app/shared/components/calendar/Calendar.stories.tsx
Normal 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} />
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { getLocalTimeZone, today } from '@internationalized/date';
|
import { getLocalTimeZone, today } from '@internationalized/date';
|
||||||
import { tv } from 'tailwind-variants';
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons';
|
import { ChevronLeftIcon, ChevronRightIcon } from '../icons';
|
||||||
|
|
||||||
import { calendarDateToDate, dateToCalendarDate } from './utils';
|
import { calendarDateToDate, dateToCalendarDate } from './utils';
|
||||||
|
|
||||||
@@ -128,8 +128,7 @@ export function Calendar({ value, defaultValue, onChange, maxValue, minValue, is
|
|||||||
isToday: !isSelected ? isToday : false,
|
isToday: !isSelected ? isToday : false,
|
||||||
isHovered: !isSelected ? isHovered : false,
|
isHovered: !isSelected ? isHovered : false,
|
||||||
isFocusVisible,
|
isFocusVisible,
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
115
web-app/app/shared/components/checkbox/Checkbox.stories.tsx
Normal file
115
web-app/app/shared/components/checkbox/Checkbox.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
}
|
||||||
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal file
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 />
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { tv } from 'tailwind-variants';
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
import { CalendarIcon } from '@/components/icons';
|
import { CalendarIcon } from '../icons';
|
||||||
|
|
||||||
import { RangeCalendar } from '../calendar/RangeCalendar';
|
import { RangeCalendar } from '../calendar/RangeCalendar';
|
||||||
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||||
|
|||||||
@@ -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: '서울특별시 강남구' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export const DescriptionsItem = (props: DescriptionsItemProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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,
|
contentClassName,
|
||||||
isFirstRow &&
|
isFirstRow &&
|
||||||
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'
|
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'
|
||||||
|
|||||||
9
web-app/app/shared/components/icons/OverlayArrow.tsx
Normal file
9
web-app/app/shared/components/icons/OverlayArrow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ export * from './Calendar';
|
|||||||
export * from './ChevronLeft';
|
export * from './ChevronLeft';
|
||||||
export * from './ChevronRight';
|
export * from './ChevronRight';
|
||||||
export * from './LoadingSpinner';
|
export * from './LoadingSpinner';
|
||||||
|
export * from './OverlayArrow';
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
101
web-app/app/shared/components/menu/Menu.stories.tsx
Normal file
101
web-app/app/shared/components/menu/Menu.stories.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
154
web-app/app/shared/components/modal/AlertModal.stories.tsx
Normal file
154
web-app/app/shared/components/modal/AlertModal.stories.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
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,95 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
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' },
|
||||||
|
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: function 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: () => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
126
web-app/app/shared/components/radio/Radio.stories.tsx
Normal file
126
web-app/app/shared/components/radio/Radio.stories.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
102
web-app/app/shared/components/radio/Radio.tsx
Normal file
102
web-app/app/shared/components/radio/Radio.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
122
web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx
Normal file
122
web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
77
web-app/app/shared/components/radioGroup/RadioGroup.tsx
Normal file
77
web-app/app/shared/components/radioGroup/RadioGroup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
web-app/app/shared/components/section/Section.stories.tsx
Normal file
96
web-app/app/shared/components/section/Section.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
}
|
||||||
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
132
web-app/app/shared/components/switch/Switch.stories.tsx
Normal file
132
web-app/app/shared/components/switch/Switch.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
}
|
||||||
59
web-app/app/shared/components/switch/Switch.tsx
Normal file
59
web-app/app/shared/components/switch/Switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
web-app/app/shared/components/tab/Tab.stories.tsx
Normal file
138
web-app/app/shared/components/tab/Tab.stories.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
55
web-app/app/shared/components/tab/Tab.tsx
Normal file
55
web-app/app/shared/components/tab/Tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
124
web-app/app/shared/components/textarea/TextArea.stories.tsx
Normal file
124
web-app/app/shared/components/textarea/TextArea.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
}
|
||||||
21
web-app/app/shared/components/textarea/TextArea.tsx
Normal file
21
web-app/app/shared/components/textarea/TextArea.tsx
Normal 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
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
web-app/app/shared/components/textarea/index.ts
Normal file
1
web-app/app/shared/components/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './TextArea';
|
||||||
106
web-app/app/shared/components/tooltip/Tooltip.stories.tsx
Normal file
106
web-app/app/shared/components/tooltip/Tooltip.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
}
|
||||||
44
web-app/app/shared/components/tooltip/Tooltip.tsx
Normal file
44
web-app/app/shared/components/tooltip/Tooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
web-app/app/shared/components/tooltip/index.ts
Normal file
1
web-app/app/shared/components/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './Tooltip';
|
||||||
156
web-app/app/shared/components/tree/Tree.stories.tsx
Normal file
156
web-app/app/shared/components/tree/Tree.stories.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
"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": {
|
||||||
|
"@internationalized/date": "^3.12.0",
|
||||||
"@react-router/node": "7.14.0",
|
"@react-router/node": "7.14.0",
|
||||||
"@react-router/serve": "7.14.0",
|
"@react-router/serve": "7.14.0",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
@@ -25,6 +28,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,11 +36,14 @@
|
|||||||
"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",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.2.0",
|
"eslint": "^10.2.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
|
|||||||
813
web-app/pnpm-lock.yaml
generated
813
web-app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user