From 52f8c30b2ef21bd0c3b120f59eeef5033e0a5592 Mon Sep 17 00:00:00 2001 From: "JoohyunKim(Lucy)" Date: Mon, 13 Apr 2026 15:27:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/breadcrumbs/Breadcrumbs.tsx | 58 ++++++++++ .../shared/components/breadcrumbs/index.ts | 5 + .../shared/components/checkbox/Checkbox.tsx | 66 ++++++++++++ .../descriptions/items/DescriptionsItem.tsx | 2 +- .../shared/components/icons/OverlayArrow.tsx | 9 ++ web-app/app/shared/components/icons/index.ts | 1 + web-app/app/shared/components/radio/Radio.tsx | 102 ++++++++++++++++++ .../components/radioGroup/RadioGroup.tsx | 77 +++++++++++++ .../app/shared/components/switch/Switch.tsx | 59 ++++++++++ web-app/app/shared/components/tab/Tab.tsx | 55 ++++++++++ .../shared/components/textarea/TextArea.tsx | 21 ++++ .../app/shared/components/textarea/index.ts | 1 + .../app/shared/components/tooltip/Tooltip.tsx | 44 ++++++++ .../app/shared/components/tooltip/index.ts | 1 + 14 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx create mode 100644 web-app/app/shared/components/breadcrumbs/index.ts create mode 100644 web-app/app/shared/components/checkbox/Checkbox.tsx create mode 100644 web-app/app/shared/components/icons/OverlayArrow.tsx create mode 100644 web-app/app/shared/components/radio/Radio.tsx create mode 100644 web-app/app/shared/components/radioGroup/RadioGroup.tsx create mode 100644 web-app/app/shared/components/switch/Switch.tsx create mode 100644 web-app/app/shared/components/tab/Tab.tsx create mode 100644 web-app/app/shared/components/textarea/TextArea.tsx create mode 100644 web-app/app/shared/components/textarea/index.ts create mode 100644 web-app/app/shared/components/tooltip/Tooltip.tsx create mode 100644 web-app/app/shared/components/tooltip/index.ts diff --git a/web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx b/web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000..519356d --- /dev/null +++ b/web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx @@ -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 = (props) => { + const { items } = props; + + return ( + + ); +}; + +export default Breadcrumbs; diff --git a/web-app/app/shared/components/breadcrumbs/index.ts b/web-app/app/shared/components/breadcrumbs/index.ts new file mode 100644 index 0000000..66298ba --- /dev/null +++ b/web-app/app/shared/components/breadcrumbs/index.ts @@ -0,0 +1,5 @@ +import type { BreadcrumbsProps } from './Breadcrumbs'; +import InternalBreadcrumbs from './Breadcrumbs'; + +export type { BreadcrumbsProps }; +export const Breadcrumbs = InternalBreadcrumbs; diff --git a/web-app/app/shared/components/checkbox/Checkbox.tsx b/web-app/app/shared/components/checkbox/Checkbox.tsx new file mode 100644 index 0000000..d94c0e2 --- /dev/null +++ b/web-app/app/shared/components/checkbox/Checkbox.tsx @@ -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 ( + checkbox({ ...renderProps, className }))} + {...restProps} + > + {composeRenderProps(children, (children, { isSelected, isIndeterminate, ...renderProps }) => ( + <> +
+ {isIndeterminate ? ( + + + + ) : isSelected ? ( + + + + ) : null} +
+ {children} + + ))} +
+ ); +}; diff --git a/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx b/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx index eb53f00..c7cc221 100644 --- a/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx +++ b/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx @@ -26,7 +26,7 @@ export const DescriptionsItem = (props: DescriptionsItemProps) => {
) { + return ( + + + + ); +} diff --git a/web-app/app/shared/components/icons/index.ts b/web-app/app/shared/components/icons/index.ts index 7df7603..59d4a79 100644 --- a/web-app/app/shared/components/icons/index.ts +++ b/web-app/app/shared/components/icons/index.ts @@ -2,4 +2,5 @@ export * from './Calendar'; export * from './ChevronLeft'; export * from './ChevronRight'; export * from './LoadingSpinner'; +export * from './OverlayArrow'; diff --git a/web-app/app/shared/components/radio/Radio.tsx b/web-app/app/shared/components/radio/Radio.tsx new file mode 100644 index 0000000..addcb58 --- /dev/null +++ b/web-app/app/shared/components/radio/Radio.tsx @@ -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 ( + + ); +}; diff --git a/web-app/app/shared/components/radioGroup/RadioGroup.tsx b/web-app/app/shared/components/radioGroup/RadioGroup.tsx new file mode 100644 index 0000000..fe4f13e --- /dev/null +++ b/web-app/app/shared/components/radioGroup/RadioGroup.tsx @@ -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 ( + + {items.map((item) => { + return ( + + {item.label} + + ); + })} + + ); +}; + +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 ( + + {composeRenderProps(props.children, (children, renderProps) => ( + <> +
+ {children} + + ))} + + ); +}; diff --git a/web-app/app/shared/components/switch/Switch.tsx b/web-app/app/shared/components/switch/Switch.tsx new file mode 100644 index 0000000..a95e33f --- /dev/null +++ b/web-app/app/shared/components/switch/Switch.tsx @@ -0,0 +1,59 @@ +import React 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 { + children?: React.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 ( + + {(renderProps) => ( + <> +
+ +
+ {children} + + )} +
+ ); +} diff --git a/web-app/app/shared/components/tab/Tab.tsx b/web-app/app/shared/components/tab/Tab.tsx new file mode 100644 index 0000000..631d402 --- /dev/null +++ b/web-app/app/shared/components/tab/Tab.tsx @@ -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 { + className?: string; + items: TabItem[]; + tabPanelClassName?: string; + tabPanelsClassName?: string; +} +export const Tab = ({ items, className, tabPanelClassName, tabPanelsClassName, ...restProps }: TabProps) => { + return ( + + + {(item) => ( + + {item.label} + + )} + + + {(item) => ( + + {item.children} + + )} + + + ); +}; diff --git a/web-app/app/shared/components/textarea/TextArea.tsx b/web-app/app/shared/components/textarea/TextArea.tsx new file mode 100644 index 0000000..4cbbb22 --- /dev/null +++ b/web-app/app/shared/components/textarea/TextArea.tsx @@ -0,0 +1,21 @@ +import { TextArea as AriaTextArea, TextAreaProps as AriaTextAreaProps } from 'react-aria-components'; + +import { twMerge } from 'tailwind-merge'; + +interface TextAreaProps extends Omit { + className?: string; +} + +export const TextArea = (props: TextAreaProps) => { + const { className, ...restProps } = props; + return ( + + ); +}; diff --git a/web-app/app/shared/components/textarea/index.ts b/web-app/app/shared/components/textarea/index.ts new file mode 100644 index 0000000..4853311 --- /dev/null +++ b/web-app/app/shared/components/textarea/index.ts @@ -0,0 +1 @@ +export * from './TextArea'; diff --git a/web-app/app/shared/components/tooltip/Tooltip.tsx b/web-app/app/shared/components/tooltip/Tooltip.tsx new file mode 100644 index 0000000..7dd0dba --- /dev/null +++ b/web-app/app/shared/components/tooltip/Tooltip.tsx @@ -0,0 +1,44 @@ +import React 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 { + bgColor?: 'white' | 'tertiary'; + children: React.ReactNode; +} + +export function Tooltip({ children, ...props }: TooltipProps) { + const { className, bgColor = 'white', offset = 10, ...restProps } = props; + + return ( + + style({ ...renderProps, className, bgColor }) + )} + > + + + + {children} + + ); +} diff --git a/web-app/app/shared/components/tooltip/index.ts b/web-app/app/shared/components/tooltip/index.ts new file mode 100644 index 0000000..7594a8f --- /dev/null +++ b/web-app/app/shared/components/tooltip/index.ts @@ -0,0 +1 @@ +export * from './Tooltip'; From 8d6bff88d6f53629a73a89b2555fec2266133655 Mon Sep 17 00:00:00 2001 From: "JoohyunKim(Lucy)" Date: Tue, 14 Apr 2026 10:38:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20storybook=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-app/.storybook/vite.config.ts | 3 +- .../breadcrumbs/Breadcrumbs.stories.tsx | 40 +++++ .../components/calendar/Calendar.stories.tsx | 62 +++++++ .../shared/components/calendar/Calendar.tsx | 5 +- .../components/checkbox/Checkbox.stories.tsx | 115 +++++++++++++ .../datePicker/DatePicker.stories.tsx | 80 +++++++++ .../components/datePicker/DateRangePicker.tsx | 2 +- .../descriptions/Descriptions.stories.tsx | 88 ++++++++++ .../shared/components/menu/Menu.stories.tsx | 101 ++++++++++++ .../components/modal/AlertModal.stories.tsx | 154 +++++++++++++++++ .../pagination/Pagination.stories.tsx | 8 +- .../shared/components/radio/Radio.stories.tsx | 126 ++++++++++++++ .../radioGroup/RadioGroup.stories.tsx | 122 ++++++++++++++ .../components/section/Section.stories.tsx | 96 +++++++++++ .../components/switch/Switch.stories.tsx | 132 +++++++++++++++ .../app/shared/components/switch/Switch.tsx | 4 +- .../app/shared/components/tab/Tab.stories.tsx | 138 ++++++++++++++++ .../components/textarea/TextArea.stories.tsx | 124 ++++++++++++++ .../components/tooltip/Tooltip.stories.tsx | 106 ++++++++++++ .../app/shared/components/tooltip/Tooltip.tsx | 4 +- .../shared/components/tree/Tree.stories.tsx | 156 ++++++++++++++++++ web-app/package.json | 2 + web-app/pnpm-lock.yaml | 29 ++++ 23 files changed, 1687 insertions(+), 10 deletions(-) create mode 100644 web-app/app/shared/components/breadcrumbs/Breadcrumbs.stories.tsx create mode 100644 web-app/app/shared/components/calendar/Calendar.stories.tsx create mode 100644 web-app/app/shared/components/checkbox/Checkbox.stories.tsx create mode 100644 web-app/app/shared/components/datePicker/DatePicker.stories.tsx create mode 100644 web-app/app/shared/components/descriptions/Descriptions.stories.tsx create mode 100644 web-app/app/shared/components/menu/Menu.stories.tsx create mode 100644 web-app/app/shared/components/modal/AlertModal.stories.tsx create mode 100644 web-app/app/shared/components/radio/Radio.stories.tsx create mode 100644 web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx create mode 100644 web-app/app/shared/components/section/Section.stories.tsx create mode 100644 web-app/app/shared/components/switch/Switch.stories.tsx create mode 100644 web-app/app/shared/components/tab/Tab.stories.tsx create mode 100644 web-app/app/shared/components/textarea/TextArea.stories.tsx create mode 100644 web-app/app/shared/components/tooltip/Tooltip.stories.tsx create mode 100644 web-app/app/shared/components/tree/Tree.stories.tsx diff --git a/web-app/.storybook/vite.config.ts b/web-app/.storybook/vite.config.ts index 50503a5..925ee7b 100644 --- a/web-app/.storybook/vite.config.ts +++ b/web-app/.storybook/vite.config.ts @@ -1,9 +1,10 @@ import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' // Storybook 전용 Vite 설정 (React Router 플러그인 제외) export default defineConfig({ - plugins: [tailwindcss()], + plugins: [react(), tailwindcss()], resolve: { alias: { '~': new URL('../app', import.meta.url).pathname, diff --git a/web-app/app/shared/components/breadcrumbs/Breadcrumbs.stories.tsx b/web-app/app/shared/components/breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 0000000..7f75410 --- /dev/null +++ b/web-app/app/shared/components/breadcrumbs/Breadcrumbs.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react' +import Breadcrumbs from './Breadcrumbs' + +const meta = { + title: 'Components/Breadcrumbs', + component: Breadcrumbs, +} satisfies Meta + +export default meta +type Story = StoryObj + +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: ['항공영상 관리 시스템', '상세 항공영상 목록 페이지', '결과 분석'], + }, +} diff --git a/web-app/app/shared/components/calendar/Calendar.stories.tsx b/web-app/app/shared/components/calendar/Calendar.stories.tsx new file mode 100644 index 0000000..de0b865 --- /dev/null +++ b/web-app/app/shared/components/calendar/Calendar.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const WithSelectedDate: Story = { + render: function Render() { + const [date, setDate] = useState(new Date()) + return + }, +} + +export const WithMinMaxDate: Story = { + render: function Render() { + const [date, setDate] = useState(new Date()) + const minDate = new Date() + minDate.setDate(minDate.getDate() - 7) + const maxDate = new Date() + maxDate.setDate(maxDate.getDate() + 7) + return + }, +} + +export const Disabled: Story = { + args: { + isDisabled: true, + }, +} + +export const Range: StoryObj = { + render: function Render() { + const [range, setRange] = useState<{ start: Date; end: Date } | null>(null) + return + }, +} + +export const RangeWithSelectedDates: StoryObj = { + 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 + }, +} diff --git a/web-app/app/shared/components/calendar/Calendar.tsx b/web-app/app/shared/components/calendar/Calendar.tsx index a15b1e6..31f44de 100644 --- a/web-app/app/shared/components/calendar/Calendar.tsx +++ b/web-app/app/shared/components/calendar/Calendar.tsx @@ -12,7 +12,7 @@ import { import { getLocalTimeZone, today } from '@internationalized/date'; import { tv } from 'tailwind-variants'; -import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons'; +import { ChevronLeftIcon, ChevronRightIcon } from '../icons'; import { calendarDateToDate, dateToCalendarDate } from './utils'; @@ -128,8 +128,7 @@ export function Calendar({ value, defaultValue, onChange, maxValue, minValue, is isToday: !isSelected ? isToday : false, isHovered: !isSelected ? isHovered : false, isFocusVisible, - }) - } + })} /> ); }} diff --git a/web-app/app/shared/components/checkbox/Checkbox.stories.tsx b/web-app/app/shared/components/checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000..20d9d50 --- /dev/null +++ b/web-app/app/shared/components/checkbox/Checkbox.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + children: '동의합니다', + }, +} + +export const Checked: Story = { + render: function Render() { + const [isSelected, setIsSelected] = useState(true) + return ( + + 선택됨 + + ) + }, +} + +export const Unchecked: Story = { + render: function Render() { + const [isSelected, setIsSelected] = useState(false) + return ( + + 선택 안됨 + + ) + }, +} + +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>({ + option1: false, + option2: true, + option3: false, + }) + + const handleChange = (key: string) => (isSelected: boolean) => { + setCheckedItems((prev) => ({ ...prev, [key]: isSelected })) + } + + return ( +
+ + 옵션 1 + + + 옵션 2 + + + 옵션 3 + +
+ ) + }, +} + +export const AllStates: Story = { + render: () => ( +
+ 기본 + 선택됨 + 일부 선택 + 비활성화 + + 비활성화 (선택됨) + +
+ ), +} diff --git a/web-app/app/shared/components/datePicker/DatePicker.stories.tsx b/web-app/app/shared/components/datePicker/DatePicker.stories.tsx new file mode 100644 index 0000000..3b74151 --- /dev/null +++ b/web-app/app/shared/components/datePicker/DatePicker.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: function Render() { + const [date, setDate] = useState(null) + return + }, +} + +export const WithSelectedDate: Story = { + render: function Render() { + const [date, setDate] = useState(new Date()) + return + }, +} + +export const WithMinMaxDate: Story = { + render: function Render() { + const [date, setDate] = useState(new Date()) + const minDate = new Date() + minDate.setDate(minDate.getDate() - 7) + const maxDate = new Date() + maxDate.setDate(maxDate.getDate() + 7) + return + }, +} + +export const Disabled: Story = { + render: function Render() { + const [date, setDate] = useState(new Date()) + return + }, +} + +export const Range: StoryObj = { + render: function Render() { + const [range, setRange] = useState<{ start: Date; end: Date } | null>(null) + return + }, +} + +export const RangeWithSelectedDates: StoryObj = { + 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 + }, +} + +export const RangeDisabled: StoryObj = { + 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 + }, +} diff --git a/web-app/app/shared/components/datePicker/DateRangePicker.tsx b/web-app/app/shared/components/datePicker/DateRangePicker.tsx index 41b99e1..1992d8d 100644 --- a/web-app/app/shared/components/datePicker/DateRangePicker.tsx +++ b/web-app/app/shared/components/datePicker/DateRangePicker.tsx @@ -10,7 +10,7 @@ import { import dayjs from 'dayjs'; import { tv } from 'tailwind-variants'; -import { CalendarIcon } from '@/components/icons'; +import { CalendarIcon } from '../icons'; import { RangeCalendar } from '../calendar/RangeCalendar'; import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils'; diff --git a/web-app/app/shared/components/descriptions/Descriptions.stories.tsx b/web-app/app/shared/components/descriptions/Descriptions.stories.tsx new file mode 100644 index 0000000..840e430 --- /dev/null +++ b/web-app/app/shared/components/descriptions/Descriptions.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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: '서울특별시 강남구' }, + ], + }, +} diff --git a/web-app/app/shared/components/menu/Menu.stories.tsx b/web-app/app/shared/components/menu/Menu.stories.tsx new file mode 100644 index 0000000..55e2730 --- /dev/null +++ b/web-app/app/shared/components/menu/Menu.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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 ( +
+ +
+ ) + }, +} + +export const WithDifferentPath: Story = { + render: function Render() { + const [currentPath, setCurrentPath] = useState('/detection/result') + const handleSelectionChange = (menu: MenuItemChildrenType) => { + setCurrentPath(menu.menuUrl) + } + return ( +
+ +
+ ) + }, +} + +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 ( +
+ +
+ ) + }, +} diff --git a/web-app/app/shared/components/modal/AlertModal.stories.tsx b/web-app/app/shared/components/modal/AlertModal.stories.tsx new file mode 100644 index 0000000..fbd8a79 --- /dev/null +++ b/web-app/app/shared/components/modal/AlertModal.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + console.log('확인 클릭')} + /> + + ) + }, +} + +export const WithoutTitle: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + console.log('확인 클릭')} + /> + + ) + }, +} + +export const CustomConfirmText: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + console.log('닫기 클릭')} + /> + + ) + }, +} + +export const MultilineContent: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + console.log('확인 클릭')} + /> + + ) + }, +} + +const confirmMeta = { + title: 'Components/Modal/ConfirmModal', + component: ConfirmModal, +} satisfies Meta + +export const Confirm: StoryObj = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + console.log('삭제 확인')} + onCancel={() => console.log('취소 클릭')} + /> + + ) + }, +} + +export const ConfirmWithCustomText: StoryObj = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + console.log('저장 확인')} + onCancel={() => console.log('저장 안 함 클릭')} + /> + + ) + }, +} + +export const ConfirmWithAsyncAction: StoryObj = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false) + const handleConfirm = async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)) + console.log('비동기 작업 완료') + } + return ( + <> + + + + ) + }, +} diff --git a/web-app/app/shared/components/pagination/Pagination.stories.tsx b/web-app/app/shared/components/pagination/Pagination.stories.tsx index 4b7e7d4..8591f87 100644 --- a/web-app/app/shared/components/pagination/Pagination.stories.tsx +++ b/web-app/app/shared/components/pagination/Pagination.stories.tsx @@ -5,6 +5,12 @@ import { Pagination } from './Pagination' const meta = { title: 'Components/Pagination', component: Pagination, + args: { + totalPages: 100, + currentPage: 0, + pageCount: 10, + onPageChange: () => {}, + }, argTypes: { totalPages: { control: 'number' }, currentPage: { control: 'number' }, @@ -61,7 +67,7 @@ export const SinglePage: Story = { } export const Interactive: Story = { - render: () => { + render: function Render() { const [currentPage, setCurrentPage] = useState(0) return (
diff --git a/web-app/app/shared/components/radio/Radio.stories.tsx b/web-app/app/shared/components/radio/Radio.stories.tsx new file mode 100644 index 0000000..ee0e5a7 --- /dev/null +++ b/web-app/app/shared/components/radio/Radio.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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 ( +
+ + 옵션 1 + + + 옵션 2 + + + 옵션 3 + +
+ ) + }, +} + +export const HorizontalRadioGroup: Story = { + render: () => { + const [selected, setSelected] = useState('option1') + return ( +
+ + 옵션 1 + + + 옵션 2 + + + 옵션 3 + +
+ ) + }, +} + +export const WithMixedStates: Story = { + render: () => { + const [selected, setSelected] = useState('option1') + return ( +
+ + 활성화 옵션 1 + + + 활성화 옵션 2 + + + 비활성화 옵션 + +
+ ) + }, +} diff --git a/web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx b/web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx new file mode 100644 index 0000000..290f01c --- /dev/null +++ b/web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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 ( + + ) + }, +} + +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 ( +
+ +

선택된 값: {value || '없음'}

+
+ ) + }, +} diff --git a/web-app/app/shared/components/section/Section.stories.tsx b/web-app/app/shared/components/section/Section.stories.tsx new file mode 100644 index 0000000..d06e6cc --- /dev/null +++ b/web-app/app/shared/components/section/Section.stories.tsx @@ -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:
Base 섹션
, + }, + argTypes: { + variant: { + control: 'select', + options: ['base', 'card', 'list', 'searchFilterGray'], + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Base: Story = { + args: { + variant: 'base', + children: ( +
+

기본 섹션

+

이것은 기본 섹션입니다.

+
+ ), + }, +} + +export const Card: Story = { + args: { + variant: 'card', + children: ( +
+

카드 섹션

+

이것은 카드 형태의 섹션입니다.

+
+ ), + }, +} + +export const List: Story = { + args: { + variant: 'list', + children: ( + <> +

리스트 섹션

+
    +
  • 항목 1
  • +
  • 항목 2
  • +
  • 항목 3
  • +
+ + ), + }, +} + +export const SearchFilterGray: Story = { + args: { + variant: 'searchFilterGray', + children: ( + <> + 필터: + + + + ), + }, +} + +export const AllVariants: Story = { + render: () => ( +
+
+
Base 섹션
+
+
+
Card 섹션
+
+
+
List 섹션
+
+
+ SearchFilterGray 섹션 +
+
+ ), +} diff --git a/web-app/app/shared/components/switch/Switch.stories.tsx b/web-app/app/shared/components/switch/Switch.stories.tsx new file mode 100644 index 0000000..0fa3de2 --- /dev/null +++ b/web-app/app/shared/components/switch/Switch.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + children: '알림 설정', + }, +} + +export const Selected: Story = { + render: function Render() { + const [isSelected, setIsSelected] = useState(true) + return ( + + 활성화됨 + + ) + }, +} + +export const Unselected: Story = { + render: function Render() { + const [isSelected, setIsSelected] = useState(false) + return ( + + 비활성화됨 + + ) + }, +} + +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 ( +
+ + 알림 받기 + +

상태: {isSelected ? '켜짐' : '꺼짐'}

+
+ ) + }, +} + +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 ( +
+ + 알림 설정 + + + 다크 모드 + + + 자동 저장 + +
+ ) + }, +} + +export const AllStates: Story = { + render: () => ( +
+ 기본 (꺼짐) + 기본 (켜짐) + 비활성화 (꺼짐) + + 비활성화 (켜짐) + + + 읽기 전용 + +
+ ), +} diff --git a/web-app/app/shared/components/switch/Switch.tsx b/web-app/app/shared/components/switch/Switch.tsx index a95e33f..3cefa08 100644 --- a/web-app/app/shared/components/switch/Switch.tsx +++ b/web-app/app/shared/components/switch/Switch.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +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 { - children?: React.ReactNode; + children?: ReactNode; } const track = tv({ diff --git a/web-app/app/shared/components/tab/Tab.stories.tsx b/web-app/app/shared/components/tab/Tab.stories.tsx new file mode 100644 index 0000000..9d99dd2 --- /dev/null +++ b/web-app/app/shared/components/tab/Tab.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +const defaultItems = [ + { key: 'tab1', label: '탭 1', children:
탭 1의 내용입니다.
}, + { key: 'tab2', label: '탭 2', children:
탭 2의 내용입니다.
}, + { key: 'tab3', label: '탭 3', children:
탭 3의 내용입니다.
}, +] + +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 ( + setSelectedKey(key as string)} + /> + ) + }, +} + +export const WithDisabledTab: Story = { + args: { + items: [ + { key: 'tab1', label: '탭 1', children:
탭 1의 내용입니다.
}, + { key: 'tab2', label: '탭 2 (비활성화)', children:
탭 2의 내용입니다.
, disabled: true }, + { key: 'tab3', label: '탭 3', children:
탭 3의 내용입니다.
}, + ], + }, +} + +export const WithRichContent: Story = { + args: { + items: [ + { + key: 'overview', + label: '개요', + children: ( +
+

프로젝트 개요

+

이 프로젝트는 항공영상 탐지 시스템입니다.

+
    +
  • 항공영상 업로드
  • +
  • AI 기반 객체 탐지
  • +
  • 결과 분석 및 리포트
  • +
+
+ ), + }, + { + key: 'details', + label: '상세 정보', + children: ( +
+

상세 정보

+ + + + + + + + + + + +
버전1.0.0
최종 수정일2024-01-15
+
+ ), + }, + { + key: 'settings', + label: '설정', + children: ( +
+

설정

+
+
+ + +
+
+
+ ), + }, + ], + }, +} + +export const ManyTabs: Story = { + args: { + items: [ + { key: 'tab1', label: '첫 번째', children:
첫 번째 탭
}, + { key: 'tab2', label: '두 번째', children:
두 번째 탭
}, + { key: 'tab3', label: '세 번째', children:
세 번째 탭
}, + { key: 'tab4', label: '네 번째', children:
네 번째 탭
}, + { key: 'tab5', label: '다섯 번째', children:
다섯 번째 탭
}, + ], + }, +} + +export const WithCustomClassName: Story = { + args: { + items: defaultItems, + className: 'bg-gray-50 p-4 rounded-lg', + tabPanelClassName: 'bg-white rounded border', + }, +} diff --git a/web-app/app/shared/components/textarea/TextArea.stories.tsx b/web-app/app/shared/components/textarea/TextArea.stories.tsx new file mode 100644 index 0000000..33449e6 --- /dev/null +++ b/web-app/app/shared/components/textarea/TextArea.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + placeholder: '내용을 입력하세요', + className: 'w-80 h-32', + }, +} + +export const WithValue: Story = { + render: function Render() { + const [value, setValue] = useState('기본 텍스트 내용입니다.') + return ( +