jongsultest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +339 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +1280 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1234 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
- package/src/atoms/Badge.stories.tsx +136 -0
- package/src/atoms/Badge.tsx +46 -0
- package/src/atoms/Button.stories.tsx +142 -0
- package/src/atoms/Button.tsx +50 -0
- package/src/atoms/ComboboxAutocomplete.stories.tsx +43 -0
- package/src/atoms/ComboboxAutocomplete.tsx +6 -0
- package/src/atoms/ComboboxSelect.stories.tsx +56 -0
- package/src/atoms/ComboboxSelect.tsx +6 -0
- package/src/atoms/DateTimePicker.stories.tsx +42 -0
- package/src/atoms/DateTimePicker.tsx +6 -0
- package/src/atoms/Icon.stories.tsx +86 -0
- package/src/atoms/Icon.tsx +81 -0
- package/src/atoms/IconButton.stories.tsx +59 -0
- package/src/atoms/IconButton.tsx +19 -0
- package/src/atoms/InputColor.stories.tsx +42 -0
- package/src/atoms/InputColor.tsx +6 -0
- package/src/atoms/InputDatePicker.stories.tsx +55 -0
- package/src/atoms/InputDatePicker.tsx +6 -0
- package/src/atoms/InputFile.stories.tsx +57 -0
- package/src/atoms/InputFile.tsx +15 -0
- package/src/atoms/InputNumber.stories.tsx +58 -0
- package/src/atoms/InputNumber.tsx +15 -0
- package/src/atoms/InputPassword.stories.tsx +42 -0
- package/src/atoms/InputPassword.tsx +24 -0
- package/src/atoms/InputRadio.stories.tsx +65 -0
- package/src/atoms/InputRadio.tsx +19 -0
- package/src/atoms/InputSegmentedControl.stories.tsx +44 -0
- package/src/atoms/InputSegmentedControl.tsx +15 -0
- package/src/atoms/InputSwitch.stories.tsx +45 -0
- package/src/atoms/InputSwitch.tsx +23 -0
- package/src/atoms/InputSwitchInTable.stories.tsx +43 -0
- package/src/atoms/InputSwitchInTable.tsx +35 -0
- package/src/atoms/InputText.stories.tsx +42 -0
- package/src/atoms/InputText.tsx +7 -0
- package/src/atoms/InputTextarea.stories.tsx +50 -0
- package/src/atoms/InputTextarea.tsx +6 -0
- package/src/atoms/index.ts +20 -0
- package/src/atoms/inputClassNames.ts +20 -0
- package/src/blocks/Accordion.stories.tsx +102 -0
- package/src/blocks/Accordion.tsx +124 -0
- package/src/blocks/AccordionDraggable.stories.tsx +169 -0
- package/src/blocks/AccordionDraggable.tsx +200 -0
- package/src/blocks/BoxContainer.stories.tsx +34 -0
- package/src/blocks/BoxContainer.tsx +16 -0
- package/src/blocks/DataTable.tsx +127 -0
- package/src/blocks/DescriptionRow.stories.tsx +34 -0
- package/src/blocks/DescriptionRow.tsx +22 -0
- package/src/blocks/EditorLayout.stories.tsx +79 -0
- package/src/blocks/EditorLayout.tsx +43 -0
- package/src/blocks/ImageUpload.tsx +292 -0
- package/src/blocks/LabeledField.stories.tsx +34 -0
- package/src/blocks/LabeledField.tsx +31 -0
- package/src/blocks/MediDrawer.stories.tsx +58 -0
- package/src/blocks/MediDrawer.tsx +34 -0
- package/src/blocks/Modal.stories.tsx +42 -0
- package/src/blocks/Modal.tsx +31 -0
- package/src/blocks/ModalDeleteConfirm.stories.tsx +31 -0
- package/src/blocks/ModalDeleteConfirm.tsx +56 -0
- package/src/blocks/ModalTokenExpired.tsx +52 -0
- package/src/blocks/NavigationBanner.tsx +100 -0
- package/src/blocks/PageHeader.tsx +24 -0
- package/src/blocks/PageLoading.stories.tsx +22 -0
- package/src/blocks/PageLoading.tsx +19 -0
- package/src/blocks/PageTitle.stories.tsx +39 -0
- package/src/blocks/PageTitle.tsx +25 -0
- package/src/blocks/Pagination.tsx +49 -0
- package/src/blocks/Panel.stories.tsx +84 -0
- package/src/blocks/Panel.tsx +97 -0
- package/src/blocks/SectionGroupWithTitle.stories.tsx +24 -0
- package/src/blocks/SectionGroupWithTitle.tsx +21 -0
- package/src/blocks/Table.tsx +114 -0
- package/src/blocks/TagInput.stories.tsx +74 -0
- package/src/blocks/TagInput.tsx +93 -0
- package/src/blocks/index.ts +25 -0
- package/src/index.ts +51 -0
- package/src/utils/file.ts +7 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { Button } from "./Button";
|
|
2
|
+
export { InputText } from "./InputText";
|
|
3
|
+
export { InputPassword } from "./InputPassword";
|
|
4
|
+
export { InputNumber } from "./InputNumber";
|
|
5
|
+
export { InputColor } from "./InputColor";
|
|
6
|
+
export { InputFile } from "./InputFile";
|
|
7
|
+
export { InputDatePicker } from "./InputDatePicker";
|
|
8
|
+
export { InputTextarea } from "./InputTextarea";
|
|
9
|
+
export { InputRadio } from "./InputRadio";
|
|
10
|
+
export { InputSwitch } from "./InputSwitch";
|
|
11
|
+
export { InputSwitchInTable } from "./InputSwitchInTable";
|
|
12
|
+
export { InputSegmentedControl } from "./InputSegmentedControl";
|
|
13
|
+
export { ComboboxSelect } from "./ComboboxSelect";
|
|
14
|
+
export { ComboboxAutocomplete } from "./ComboboxAutocomplete";
|
|
15
|
+
export { DateTimePicker } from "./DateTimePicker";
|
|
16
|
+
export { Badge } from "./Badge";
|
|
17
|
+
export { IconButton } from "./IconButton";
|
|
18
|
+
export { PlusIcon, TrashIcon, GripVerticalIcon, ChevronDownIcon } from "./Icon";
|
|
19
|
+
export type { IconSvgProps } from "./Icon";
|
|
20
|
+
export { inputClassNames } from "./inputClassNames";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const inputClassNames = {
|
|
2
|
+
input: `!rounded-[8px] !border-input
|
|
3
|
+
!text-sm !h-12
|
|
4
|
+
|
|
5
|
+
data-[error=true]:!border-error-line
|
|
6
|
+
data-[error=true]:!border-2
|
|
7
|
+
data-[error=true]:!bg-error-fill
|
|
8
|
+
data-[error=true]:!text-body
|
|
9
|
+
|
|
10
|
+
data-[disabled=true]:!text-body
|
|
11
|
+
data-[disabled=true]:!bg-disabled-input
|
|
12
|
+
data-[disabled=true]:!opacity-100
|
|
13
|
+
data-[disabled=true]:!border-default
|
|
14
|
+
`,
|
|
15
|
+
label: "!text-sm !text-[#999] !font-bold !tracking-tight !mb-2",
|
|
16
|
+
placeholder: "!text-sm !tracking-normal",
|
|
17
|
+
error: "!mt-2 !ml-2",
|
|
18
|
+
description: "!-mt-[.5px] !mb-2",
|
|
19
|
+
root: "!leading-0",
|
|
20
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Accordion } from "./Accordion";
|
|
3
|
+
import { Badge, Text } from "@mantine/core";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Accordion> = {
|
|
6
|
+
title: "Blocks/Accordion",
|
|
7
|
+
component: Accordion,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof Accordion>;
|
|
13
|
+
|
|
14
|
+
const sampleItems = [
|
|
15
|
+
{
|
|
16
|
+
id: "1",
|
|
17
|
+
title: "시술 안내",
|
|
18
|
+
content: <Text size="sm">시술에 대한 상세 안내 내용입니다.</Text>,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "2",
|
|
22
|
+
title: "주의 사항",
|
|
23
|
+
content: <Text size="sm">시술 전후 주의 사항을 확인해주세요.</Text>,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "3",
|
|
27
|
+
title: "자주 묻는 질문",
|
|
28
|
+
content: <Text size="sm">FAQ 내용이 들어갑니다.</Text>,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const Default: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
items: sampleItems,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const SingleOpen: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
items: sampleItems,
|
|
41
|
+
multiple: false,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const WithDefaultOpen: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
items: sampleItems,
|
|
48
|
+
defaultOpen: ["1", "3"],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const WithIcon: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
items: sampleItems.map((item, index) => ({
|
|
55
|
+
...item,
|
|
56
|
+
icon: <Badge size="sm">{index + 1}</Badge>,
|
|
57
|
+
})),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const WithRightSection: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
items: sampleItems.map((item) => ({
|
|
64
|
+
...item,
|
|
65
|
+
rightSection: (
|
|
66
|
+
<Badge variant="light" color="blue" size="sm">
|
|
67
|
+
NEW
|
|
68
|
+
</Badge>
|
|
69
|
+
),
|
|
70
|
+
})),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const WithDescription: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
items: sampleItems.map((item) => ({
|
|
77
|
+
...item,
|
|
78
|
+
description: "항목에 대한 부가 설명입니다.",
|
|
79
|
+
})),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const WithDisabled: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
items: [
|
|
86
|
+
...sampleItems,
|
|
87
|
+
{
|
|
88
|
+
id: "4",
|
|
89
|
+
title: "비활성화된 항목",
|
|
90
|
+
content: <Text size="sm">이 항목은 열 수 없습니다.</Text>,
|
|
91
|
+
disabled: true,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const Contained: Story = {
|
|
98
|
+
args: {
|
|
99
|
+
items: sampleItems,
|
|
100
|
+
variant: "contained",
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Accordion as MantineAccordion,
|
|
6
|
+
AccordionProps as MantineAccordionProps,
|
|
7
|
+
Text,
|
|
8
|
+
Group,
|
|
9
|
+
Stack,
|
|
10
|
+
} from "@mantine/core";
|
|
11
|
+
import { ChevronDownIcon } from "../atoms";
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================
|
|
16
|
+
|
|
17
|
+
export interface AccordionItemData {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
icon?: ReactNode;
|
|
22
|
+
rightSection?: ReactNode;
|
|
23
|
+
content: ReactNode;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AccordionProps {
|
|
28
|
+
items: AccordionItemData[];
|
|
29
|
+
defaultOpen?: string[];
|
|
30
|
+
multiple?: boolean;
|
|
31
|
+
variant?: MantineAccordionProps["variant"];
|
|
32
|
+
onChange?: (value: string | string[] | null) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// Component
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
export function Accordion({
|
|
40
|
+
items,
|
|
41
|
+
defaultOpen = [],
|
|
42
|
+
multiple = true,
|
|
43
|
+
variant = "separated",
|
|
44
|
+
onChange,
|
|
45
|
+
}: AccordionProps) {
|
|
46
|
+
if (multiple) {
|
|
47
|
+
return (
|
|
48
|
+
<MantineAccordion
|
|
49
|
+
variant={variant}
|
|
50
|
+
multiple
|
|
51
|
+
defaultValue={defaultOpen}
|
|
52
|
+
chevron={<ChevronDownIcon />}
|
|
53
|
+
styles={accordionStyles}
|
|
54
|
+
onChange={onChange as (value: string[]) => void}
|
|
55
|
+
classNames={{ label: "!py-4" }}
|
|
56
|
+
>
|
|
57
|
+
{items.map((item) => (
|
|
58
|
+
<AccordionItem key={item.id} item={item} />
|
|
59
|
+
))}
|
|
60
|
+
</MantineAccordion>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<MantineAccordion
|
|
66
|
+
variant={variant}
|
|
67
|
+
defaultValue={defaultOpen[0]}
|
|
68
|
+
chevron={<ChevronDownIcon />}
|
|
69
|
+
styles={accordionStyles}
|
|
70
|
+
onChange={onChange as (value: string | null) => void}
|
|
71
|
+
>
|
|
72
|
+
{items.map((item) => (
|
|
73
|
+
<AccordionItem key={item.id} item={item} />
|
|
74
|
+
))}
|
|
75
|
+
</MantineAccordion>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================
|
|
80
|
+
// Sub Components
|
|
81
|
+
// ============================================
|
|
82
|
+
|
|
83
|
+
function AccordionItem({ item }: { item: AccordionItemData }) {
|
|
84
|
+
return (
|
|
85
|
+
<MantineAccordion.Item value={item.id}>
|
|
86
|
+
<MantineAccordion.Control disabled={item.disabled} icon={item.icon}>
|
|
87
|
+
<Group justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
|
|
88
|
+
<Text fw={500}>{item.title}</Text>
|
|
89
|
+
{item.rightSection && <div onClick={(e) => e.stopPropagation()}>{item.rightSection}</div>}
|
|
90
|
+
</Group>
|
|
91
|
+
</MantineAccordion.Control>
|
|
92
|
+
<MantineAccordion.Panel>
|
|
93
|
+
<Stack gap="md">
|
|
94
|
+
{item.description && (
|
|
95
|
+
<Text size="sm" c="dimmed">
|
|
96
|
+
{item.description}
|
|
97
|
+
</Text>
|
|
98
|
+
)}
|
|
99
|
+
{item.content}
|
|
100
|
+
</Stack>
|
|
101
|
+
</MantineAccordion.Panel>
|
|
102
|
+
</MantineAccordion.Item>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================
|
|
107
|
+
// Shared Styles
|
|
108
|
+
// ============================================
|
|
109
|
+
|
|
110
|
+
const accordionStyles = {
|
|
111
|
+
control: { padding: "17px 24px" },
|
|
112
|
+
item: { "--item-filled-color": "#F3F9FF" },
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ============================================
|
|
116
|
+
// Compound Component (유연한 사용을 위한 대안)
|
|
117
|
+
// ============================================
|
|
118
|
+
|
|
119
|
+
Accordion.Root = ((props: MantineAccordionProps) => (
|
|
120
|
+
<MantineAccordion chevron={<ChevronDownIcon />} styles={accordionStyles} {...props} />
|
|
121
|
+
)) as typeof MantineAccordion;
|
|
122
|
+
Accordion.Item = MantineAccordion.Item;
|
|
123
|
+
Accordion.Control = MantineAccordion.Control;
|
|
124
|
+
Accordion.Panel = MantineAccordion.Panel;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { AccordionDraggable, AccordionDraggableItem } from "./AccordionDraggable";
|
|
3
|
+
import { InputText, InputTextarea } from "../atoms";
|
|
4
|
+
import { Stack, Text } from "@mantine/core";
|
|
5
|
+
import { DropResult } from "@hello-pangea/dnd";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof AccordionDraggable> = {
|
|
9
|
+
title: "Blocks/AccordionDraggable",
|
|
10
|
+
component: AccordionDraggable,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof AccordionDraggable>;
|
|
16
|
+
|
|
17
|
+
const sampleItems: AccordionDraggableItem[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "1",
|
|
20
|
+
title: "이름",
|
|
21
|
+
content: (
|
|
22
|
+
<Stack gap="sm">
|
|
23
|
+
<InputText label="라벨명" placeholder="라벨명을 입력하세요" />
|
|
24
|
+
</Stack>
|
|
25
|
+
),
|
|
26
|
+
isRequired: true,
|
|
27
|
+
isUsed: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "2",
|
|
31
|
+
title: "전화번호",
|
|
32
|
+
content: (
|
|
33
|
+
<Stack gap="sm">
|
|
34
|
+
<InputText label="라벨명" placeholder="라벨명을 입력하세요" />
|
|
35
|
+
</Stack>
|
|
36
|
+
),
|
|
37
|
+
isRequired: true,
|
|
38
|
+
isUsed: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "3",
|
|
42
|
+
title: "상담 내용",
|
|
43
|
+
content: (
|
|
44
|
+
<Stack gap="sm">
|
|
45
|
+
<InputTextarea label="설명" placeholder="설명을 입력하세요" />
|
|
46
|
+
</Stack>
|
|
47
|
+
),
|
|
48
|
+
isRequired: false,
|
|
49
|
+
isUsed: false,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const handleReorder = (result: DropResult) => {
|
|
54
|
+
console.log("reorder", result);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function DefaultExample() {
|
|
58
|
+
const [items, setItems] = useState<AccordionDraggableItem[]>(sampleItems);
|
|
59
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
const handleInteractiveReorder = (result: DropResult) => {
|
|
62
|
+
if (!result.destination) return;
|
|
63
|
+
const reordered = [...items];
|
|
64
|
+
const [moved] = reordered.splice(result.source.index, 1);
|
|
65
|
+
reordered.splice(result.destination.index, 0, moved);
|
|
66
|
+
setItems(reordered);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<AccordionDraggable
|
|
71
|
+
items={items}
|
|
72
|
+
activeId={activeId}
|
|
73
|
+
onActiveChange={setActiveId}
|
|
74
|
+
onReorder={handleInteractiveReorder}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Default: Story = {
|
|
80
|
+
render: () => <DefaultExample />,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const WithDeleteButton: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
items: sampleItems,
|
|
86
|
+
onReorder: handleReorder,
|
|
87
|
+
showDeleteButton: true,
|
|
88
|
+
onDelete: (id: string) => console.log("delete", id),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const WithToggles: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
items: sampleItems,
|
|
95
|
+
onReorder: handleReorder,
|
|
96
|
+
showDeleteButton: false,
|
|
97
|
+
showRequiredToggle: true,
|
|
98
|
+
showUsedToggle: true,
|
|
99
|
+
onRequiredChange: (id: string, value: boolean) => console.log("required", id, value),
|
|
100
|
+
onUsedChange: (id: string, value: boolean) => console.log("used", id, value),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const WithRightSection: Story = {
|
|
105
|
+
args: {
|
|
106
|
+
items: sampleItems.map((item) => ({
|
|
107
|
+
...item,
|
|
108
|
+
rightSection: (
|
|
109
|
+
<Text size="xs" c="dimmed">
|
|
110
|
+
커스텀 영역
|
|
111
|
+
</Text>
|
|
112
|
+
),
|
|
113
|
+
})),
|
|
114
|
+
onReorder: handleReorder,
|
|
115
|
+
showDeleteButton: false,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const Empty: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
items: [],
|
|
122
|
+
onReorder: handleReorder,
|
|
123
|
+
emptyMessage: "등록된 항목이 없습니다",
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function InteractiveExample() {
|
|
128
|
+
const [items, setItems] = useState<AccordionDraggableItem[]>(sampleItems);
|
|
129
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
130
|
+
|
|
131
|
+
const handleInteractiveReorder = (result: DropResult) => {
|
|
132
|
+
if (!result.destination) return;
|
|
133
|
+
const reordered = [...items];
|
|
134
|
+
const [moved] = reordered.splice(result.source.index, 1);
|
|
135
|
+
reordered.splice(result.destination.index, 0, moved);
|
|
136
|
+
setItems(reordered);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleDelete = (id: string) => {
|
|
140
|
+
setItems((prev) => prev.filter((item) => item.id !== id));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const handleRequiredChange = (id: string, value: boolean) => {
|
|
144
|
+
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, isRequired: value } : item)));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const handleUsedChange = (id: string, value: boolean) => {
|
|
148
|
+
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, isUsed: value } : item)));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<AccordionDraggable
|
|
153
|
+
items={items}
|
|
154
|
+
activeId={activeId}
|
|
155
|
+
onActiveChange={setActiveId}
|
|
156
|
+
onReorder={handleInteractiveReorder}
|
|
157
|
+
onDelete={handleDelete}
|
|
158
|
+
onRequiredChange={handleRequiredChange}
|
|
159
|
+
onUsedChange={handleUsedChange}
|
|
160
|
+
showDeleteButton={true}
|
|
161
|
+
showRequiredToggle={true}
|
|
162
|
+
showUsedToggle={true}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const Interactive: Story = {
|
|
168
|
+
render: () => <InteractiveExample />,
|
|
169
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { GripVerticalIcon, TrashIcon } from "../atoms";
|
|
4
|
+
import { Accordion } from ".";
|
|
5
|
+
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
|
6
|
+
import { Box, Checkbox, Group, Stack, Text } from "@mantine/core";
|
|
7
|
+
import React, { ReactNode, useEffect, useState } from "react";
|
|
8
|
+
|
|
9
|
+
// ============================================
|
|
10
|
+
// Types
|
|
11
|
+
// ============================================
|
|
12
|
+
|
|
13
|
+
export interface AccordionDraggableItem {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
content: ReactNode;
|
|
17
|
+
icon?: ReactNode;
|
|
18
|
+
rightSection?: ReactNode;
|
|
19
|
+
isRequired?: boolean;
|
|
20
|
+
isUsed?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AccordionDraggableProps {
|
|
24
|
+
items: AccordionDraggableItem[];
|
|
25
|
+
droppableId?: string;
|
|
26
|
+
activeId?: string | null;
|
|
27
|
+
onActiveChange?: (id: string | null) => void;
|
|
28
|
+
onReorder: (result: DropResult) => void;
|
|
29
|
+
onDelete?: (id: string) => void;
|
|
30
|
+
onRequiredChange?: (id: string, value: boolean) => void;
|
|
31
|
+
onUsedChange?: (id: string, value: boolean) => void;
|
|
32
|
+
emptyMessage?: string;
|
|
33
|
+
showDeleteButton?: boolean;
|
|
34
|
+
showRequiredToggle?: boolean;
|
|
35
|
+
showUsedToggle?: boolean;
|
|
36
|
+
draggingBgColor?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// Droppable wrapper — 마운트 후 활성화하여 hydration 불일치 방지
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
function StrictModeDroppable({ children, ...props }: React.ComponentProps<typeof Droppable>) {
|
|
44
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const animation = requestAnimationFrame(() => setIsEnabled(true));
|
|
48
|
+
return () => {
|
|
49
|
+
cancelAnimationFrame(animation);
|
|
50
|
+
setIsEnabled(false);
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
if (!isEnabled) return null;
|
|
55
|
+
|
|
56
|
+
return <Droppable {...props}>{children}</Droppable>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================
|
|
60
|
+
// Component
|
|
61
|
+
// ============================================
|
|
62
|
+
|
|
63
|
+
export function AccordionDraggable({
|
|
64
|
+
items,
|
|
65
|
+
droppableId = "draggable-accordion",
|
|
66
|
+
activeId = null,
|
|
67
|
+
onActiveChange,
|
|
68
|
+
onReorder,
|
|
69
|
+
onDelete,
|
|
70
|
+
onRequiredChange,
|
|
71
|
+
onUsedChange,
|
|
72
|
+
emptyMessage = "항목이 없습니다",
|
|
73
|
+
showDeleteButton = true,
|
|
74
|
+
showRequiredToggle = false,
|
|
75
|
+
showUsedToggle = false,
|
|
76
|
+
draggingBgColor = "var(--color-dragging)",
|
|
77
|
+
}: AccordionDraggableProps) {
|
|
78
|
+
if (items.length === 0) {
|
|
79
|
+
return (
|
|
80
|
+
<Box style={{ padding: 40, textAlign: "center" }}>
|
|
81
|
+
<Text c="dimmed">{emptyMessage}</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<DragDropContext onDragEnd={onReorder}>
|
|
88
|
+
<StrictModeDroppable droppableId={droppableId}>
|
|
89
|
+
{(provided) => (
|
|
90
|
+
<Box ref={provided.innerRef} {...provided.droppableProps}>
|
|
91
|
+
<Accordion.Root
|
|
92
|
+
variant="separated"
|
|
93
|
+
value={activeId}
|
|
94
|
+
onChange={onActiveChange}
|
|
95
|
+
classNames={{ label: "!py-0", content: "!p-6 !pb-10" }}
|
|
96
|
+
>
|
|
97
|
+
{items.map((item, index) => (
|
|
98
|
+
<Draggable key={item.id} draggableId={item.id} index={index}>
|
|
99
|
+
{(provided) => (
|
|
100
|
+
<Box
|
|
101
|
+
ref={provided.innerRef}
|
|
102
|
+
{...provided.draggableProps}
|
|
103
|
+
style={{
|
|
104
|
+
...provided.draggableProps.style,
|
|
105
|
+
marginBottom: 8,
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<Accordion.Item
|
|
109
|
+
value={item.id}
|
|
110
|
+
data-active={activeId === item.id || undefined}
|
|
111
|
+
style={{ backgroundColor: draggingBgColor }}
|
|
112
|
+
className="group data-[active]:!bg-[var(--color-active,transparent)]"
|
|
113
|
+
>
|
|
114
|
+
<Accordion.Control
|
|
115
|
+
icon={item.icon}
|
|
116
|
+
className="group-data-[active]:!border-b group-data-[active]:!border-gray-200"
|
|
117
|
+
>
|
|
118
|
+
<Group gap="xs" wrap="nowrap" style={{ width: "100%" }}>
|
|
119
|
+
{/* 드래그 핸들 */}
|
|
120
|
+
<span {...provided.dragHandleProps} onClick={(e) => e.stopPropagation()}>
|
|
121
|
+
<GripVerticalIcon />
|
|
122
|
+
</span>
|
|
123
|
+
|
|
124
|
+
{/* 제목 */}
|
|
125
|
+
<Text fw={500} className="flex-1 !text-[15px]">
|
|
126
|
+
{item.title}
|
|
127
|
+
</Text>
|
|
128
|
+
|
|
129
|
+
{/* 오른쪽 섹션 */}
|
|
130
|
+
{item.rightSection && (
|
|
131
|
+
<Box
|
|
132
|
+
onClick={(e) => e.stopPropagation()}
|
|
133
|
+
style={{
|
|
134
|
+
marginRight: 16,
|
|
135
|
+
padding: "4px 8px",
|
|
136
|
+
borderRadius: 4,
|
|
137
|
+
backgroundColor: "var(--color-right-section)",
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{item.rightSection}
|
|
141
|
+
</Box>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{/* 사용/필수 토글 */}
|
|
145
|
+
{(showRequiredToggle || showUsedToggle) && !item.rightSection && (
|
|
146
|
+
<Group
|
|
147
|
+
gap="sm"
|
|
148
|
+
wrap="nowrap"
|
|
149
|
+
className="mr-6 h-5 border-r border-[#E1E4E8] pr-6"
|
|
150
|
+
onClick={(e) => e.stopPropagation()}
|
|
151
|
+
>
|
|
152
|
+
{/* 사용 토글 */}
|
|
153
|
+
{showUsedToggle && onUsedChange && (
|
|
154
|
+
<Checkbox
|
|
155
|
+
checked={item.isUsed ?? false}
|
|
156
|
+
onChange={(e) => onUsedChange(item.id, e.currentTarget.checked)}
|
|
157
|
+
label="사용"
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
{/* 필수 토글 */}
|
|
161
|
+
{showRequiredToggle && onRequiredChange && (
|
|
162
|
+
<Checkbox
|
|
163
|
+
checked={item.isRequired ?? false}
|
|
164
|
+
onChange={(e) => onRequiredChange(item.id, e.currentTarget.checked)}
|
|
165
|
+
label="필수"
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
168
|
+
</Group>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* 삭제 버튼 */}
|
|
172
|
+
{showDeleteButton && onDelete && !item.rightSection && (
|
|
173
|
+
<span
|
|
174
|
+
className="mr-6 h-5 border-r border-[#E1E4E8] pr-6"
|
|
175
|
+
onClick={(e) => {
|
|
176
|
+
e.stopPropagation();
|
|
177
|
+
onDelete(item.id);
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<TrashIcon color="red" />
|
|
181
|
+
</span>
|
|
182
|
+
)}
|
|
183
|
+
</Group>
|
|
184
|
+
</Accordion.Control>
|
|
185
|
+
<Accordion.Panel>
|
|
186
|
+
<Stack gap="24">{item.content}</Stack>
|
|
187
|
+
</Accordion.Panel>
|
|
188
|
+
</Accordion.Item>
|
|
189
|
+
</Box>
|
|
190
|
+
)}
|
|
191
|
+
</Draggable>
|
|
192
|
+
))}
|
|
193
|
+
{provided.placeholder}
|
|
194
|
+
</Accordion.Root>
|
|
195
|
+
</Box>
|
|
196
|
+
)}
|
|
197
|
+
</StrictModeDroppable>
|
|
198
|
+
</DragDropContext>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import BoxContainer from "./BoxContainer";
|
|
3
|
+
import { Text } from "@mantine/core";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof BoxContainer> = {
|
|
6
|
+
title: "Blocks/BoxContainer",
|
|
7
|
+
component: BoxContainer,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: "fullscreen",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof BoxContainer>;
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
args: {
|
|
19
|
+
children: <Text>기본 컨텐츠 영역입니다.</Text>,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const WithMultipleChildren: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
children: (
|
|
26
|
+
<>
|
|
27
|
+
<Text fw={700} size="xl" mb="md">
|
|
28
|
+
페이지 제목
|
|
29
|
+
</Text>
|
|
30
|
+
<Text c="dimmed">페이지 설명 텍스트가 들어갑니다.</Text>
|
|
31
|
+
</>
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Box } from "@mantine/core";
|
|
2
|
+
import { PropsWithChildren } from "react";
|
|
3
|
+
|
|
4
|
+
export default function BoxContainer({ children }: PropsWithChildren) {
|
|
5
|
+
return (
|
|
6
|
+
<Box
|
|
7
|
+
component="main"
|
|
8
|
+
style={{
|
|
9
|
+
flexGrow: 1,
|
|
10
|
+
padding: 24,
|
|
11
|
+
}}
|
|
12
|
+
>
|
|
13
|
+
{children}
|
|
14
|
+
</Box>
|
|
15
|
+
);
|
|
16
|
+
}
|