atomsystem 0.1.41 → 0.1.43
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/app/templates/Collapse/Collapse.tsx +207 -115
- package/app/templates/Collapse/collapse.css +1 -10
- package/app/templates/TOC/TOC.tsx +122 -20
- package/package.json +1 -1
|
@@ -1,115 +1,207 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, ReactNode, useRef } from "react";
|
|
4
|
+
import gsap from 'gsap';
|
|
5
|
+
import { useGSAP } from '@gsap/react';
|
|
6
|
+
|
|
7
|
+
gsap.registerPlugin(useGSAP);
|
|
8
|
+
|
|
9
|
+
// Types
|
|
10
|
+
export interface CollapseProps {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
type?: "accordion" | "collapse";
|
|
13
|
+
defaultActiveKeys?: (string | number)[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CollapseContextValue {
|
|
17
|
+
type: "accordion" | "collapse";
|
|
18
|
+
activeKeys: Set<string | number>;
|
|
19
|
+
toggle: (key: string | number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CollapseContext = createContext<CollapseContextValue | undefined>(undefined);
|
|
23
|
+
|
|
24
|
+
const useCollapseContext = () => {
|
|
25
|
+
const context = useContext(CollapseContext);
|
|
26
|
+
if (!context) {
|
|
27
|
+
return { type: "collapse" as const, activeKeys: new Set<string | number>(), toggle: () => {} };
|
|
28
|
+
}
|
|
29
|
+
return context;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Provider
|
|
33
|
+
const CollapseProvider = ({
|
|
34
|
+
children,
|
|
35
|
+
type = "collapse",
|
|
36
|
+
defaultActiveKeys = []
|
|
37
|
+
}: CollapseProps) => {
|
|
38
|
+
const [activeKeys, setActiveKeys] = useState<Set<string | number>>(new Set(defaultActiveKeys));
|
|
39
|
+
|
|
40
|
+
const toggle = (key: string | number) => {
|
|
41
|
+
setActiveKeys((prev) => {
|
|
42
|
+
const next = new Set(prev);
|
|
43
|
+
if (next.has(key)) {
|
|
44
|
+
next.delete(key);
|
|
45
|
+
} else {
|
|
46
|
+
if (type === "accordion") {
|
|
47
|
+
next.clear();
|
|
48
|
+
}
|
|
49
|
+
next.add(key);
|
|
50
|
+
}
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<CollapseContext.Provider value={{ type, activeKeys, toggle }}>
|
|
57
|
+
{children}
|
|
58
|
+
</CollapseContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Root Container
|
|
63
|
+
const Collapse = ({ children, className }: { children: ReactNode; className?: string }) => {
|
|
64
|
+
return <div className={`collapse-group ${className || ""}`}>{children}</div>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Item Context for child components
|
|
68
|
+
const ItemContext = createContext<{ value: string | number } | undefined>(undefined);
|
|
69
|
+
const useItemContext = () => {
|
|
70
|
+
const context = useContext(ItemContext);
|
|
71
|
+
if (!context) throw new Error("Collapse sub-components must be used within CollapseItem");
|
|
72
|
+
return context;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const CollapseItem = ({
|
|
76
|
+
value,
|
|
77
|
+
children,
|
|
78
|
+
className
|
|
79
|
+
}: {
|
|
80
|
+
value: string | number;
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
className?: string;
|
|
83
|
+
}) => {
|
|
84
|
+
const { activeKeys } = useCollapseContext();
|
|
85
|
+
const isOpen = activeKeys.has(value);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<ItemContext.Provider value={{ value }}>
|
|
89
|
+
<div
|
|
90
|
+
className={`collapse-item ${isOpen ? "open" : "closed"} ${className || ""}`}
|
|
91
|
+
data-state={isOpen ? "open" : "closed"}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</div>
|
|
95
|
+
</ItemContext.Provider>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const CollapseHeader = ({
|
|
100
|
+
children,
|
|
101
|
+
className
|
|
102
|
+
}: {
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
className?: string;
|
|
105
|
+
}) => {
|
|
106
|
+
const { value } = useItemContext();
|
|
107
|
+
const { activeKeys, toggle } = useCollapseContext();
|
|
108
|
+
const isOpen = activeKeys.has(value);
|
|
109
|
+
const iconRef = useRef<HTMLSpanElement>(null);
|
|
110
|
+
|
|
111
|
+
useGSAP(() => {
|
|
112
|
+
if (iconRef.current) {
|
|
113
|
+
gsap.to(iconRef.current, {
|
|
114
|
+
rotation: isOpen ? 90 : 0,
|
|
115
|
+
duration: 0.4,
|
|
116
|
+
ease: "power2.inOut",
|
|
117
|
+
overwrite: true
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}, [isOpen]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
className={`collapse-header cursor-pointer flex items-center justify-between ${className || ""}`}
|
|
125
|
+
onClick={() => toggle(value)}
|
|
126
|
+
>
|
|
127
|
+
{children}
|
|
128
|
+
<span
|
|
129
|
+
ref={iconRef}
|
|
130
|
+
className="material-symbols-outlined collapse-icon"
|
|
131
|
+
>
|
|
132
|
+
keyboard_arrow_right
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const CollapseContent = ({
|
|
139
|
+
children,
|
|
140
|
+
className
|
|
141
|
+
}: {
|
|
142
|
+
children: ReactNode;
|
|
143
|
+
className?: string;
|
|
144
|
+
}) => {
|
|
145
|
+
const { value } = useItemContext();
|
|
146
|
+
const { activeKeys } = useCollapseContext();
|
|
147
|
+
const isOpen = activeKeys.has(value);
|
|
148
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
149
|
+
|
|
150
|
+
useGSAP(() => {
|
|
151
|
+
if (!contentRef.current) return;
|
|
152
|
+
|
|
153
|
+
if (isOpen) {
|
|
154
|
+
// 열기: 현재 높이가 0이거나 display: none인 상태에서도 부드럽게 시작할 수 있도록 처리
|
|
155
|
+
gsap.fromTo(contentRef.current,
|
|
156
|
+
{ height: 0, opacity: 0, display: "block" },
|
|
157
|
+
{
|
|
158
|
+
height: "auto",
|
|
159
|
+
opacity: 1,
|
|
160
|
+
duration: 0.4,
|
|
161
|
+
ease: "power2.out",
|
|
162
|
+
overwrite: true,
|
|
163
|
+
onComplete: () => {
|
|
164
|
+
// 애니메이션 완료 후 height를 auto로 고정하여 반응형 대응
|
|
165
|
+
if (contentRef.current) {
|
|
166
|
+
gsap.set(contentRef.current, { clearProps: "height" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
// 닫기
|
|
173
|
+
gsap.to(contentRef.current, {
|
|
174
|
+
height: 0,
|
|
175
|
+
opacity: 0,
|
|
176
|
+
duration: 0.3,
|
|
177
|
+
ease: "power2.inOut",
|
|
178
|
+
overwrite: true,
|
|
179
|
+
onComplete: () => {
|
|
180
|
+
if (contentRef.current && !isOpen) {
|
|
181
|
+
gsap.set(contentRef.current, { display: "none" });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}, [isOpen]);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div
|
|
190
|
+
ref={contentRef}
|
|
191
|
+
className={`collapse-content overflow-hidden ${className || ""}`}
|
|
192
|
+
style={{ height: 0, opacity: 0, display: "none" }}
|
|
193
|
+
>
|
|
194
|
+
<div style={{ padding: "var(--spacing-common-x)" }}>
|
|
195
|
+
{children}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export {
|
|
202
|
+
Collapse,
|
|
203
|
+
CollapseProvider,
|
|
204
|
+
CollapseItem,
|
|
205
|
+
CollapseHeader,
|
|
206
|
+
CollapseContent
|
|
207
|
+
};
|
|
@@ -14,20 +14,11 @@
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
.collapse-icon {
|
|
17
|
-
transition: transform 0.3s ease;
|
|
18
17
|
color: var(--text-standard);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.collapse-icon.open {
|
|
22
|
-
transform: rotate(90deg);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
.collapse-icon.closed {
|
|
26
|
-
transform: rotate(0deg);
|
|
18
|
+
display: inline-block; /* 회전 중심을 위해 적용 */
|
|
27
19
|
}
|
|
28
20
|
|
|
29
21
|
.collapse-content {
|
|
30
|
-
padding: var(--spacing-common-x);
|
|
31
22
|
color: var(--text-standard);
|
|
32
23
|
font-size: var(--text-body);
|
|
33
24
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { usePathname } from "next/navigation";
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
+
import gsap from "gsap";
|
|
5
|
+
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
|
|
6
|
+
|
|
7
|
+
if (typeof window !== "undefined") {
|
|
8
|
+
gsap.registerPlugin(ScrollToPlugin);
|
|
9
|
+
}
|
|
4
10
|
|
|
5
11
|
type Heading = {
|
|
6
12
|
id: string;
|
|
@@ -10,10 +16,18 @@ type Heading = {
|
|
|
10
16
|
|
|
11
17
|
export default function TOC() {
|
|
12
18
|
const [headings, setHeadings] = useState<Heading[]>([]);
|
|
19
|
+
const [activeId, setActiveId] = useState<string>("");
|
|
20
|
+
const [mounted, setMounted] = useState(false);
|
|
13
21
|
|
|
14
|
-
const pathname = usePathname();
|
|
22
|
+
const pathname = usePathname();
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setMounted(true);
|
|
26
|
+
}, []);
|
|
15
27
|
|
|
16
28
|
useEffect(() => {
|
|
29
|
+
if (!mounted) return;
|
|
30
|
+
|
|
17
31
|
const timer = setTimeout(() => {
|
|
18
32
|
/**
|
|
19
33
|
* Document에서 읽히는 h1, h2, h3 태그를 array 형태로 정의
|
|
@@ -22,42 +36,130 @@ export default function TOC() {
|
|
|
22
36
|
document.querySelectorAll("h1, h2, h3")
|
|
23
37
|
) as HTMLHeadingElement[];
|
|
24
38
|
|
|
39
|
+
const usedIds = new Set<string>();
|
|
25
40
|
const generatedHeadings: Heading[] = headingElements.map((heading) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
let finalId = heading.id;
|
|
42
|
+
|
|
43
|
+
// ID가 없거나 중복된 경우 새로 생성
|
|
44
|
+
if (!finalId || usedIds.has(finalId)) {
|
|
45
|
+
const baseId =
|
|
29
46
|
heading.textContent
|
|
30
47
|
?.toLowerCase()
|
|
48
|
+
.trim()
|
|
31
49
|
.replace(/\s+/g, "-")
|
|
32
|
-
.replace(/[^\w\-가-힣]+/g, "")
|
|
50
|
+
.replace(/[^\w\-가-힣]+/g, "") || "heading";
|
|
51
|
+
|
|
52
|
+
finalId = baseId;
|
|
53
|
+
let counter = 1;
|
|
54
|
+
while (usedIds.has(finalId)) {
|
|
55
|
+
finalId = `${baseId}-${counter}`;
|
|
56
|
+
counter++;
|
|
57
|
+
}
|
|
58
|
+
heading.id = finalId;
|
|
33
59
|
}
|
|
34
60
|
|
|
61
|
+
usedIds.add(finalId);
|
|
62
|
+
|
|
35
63
|
return {
|
|
36
|
-
id:
|
|
64
|
+
id: finalId,
|
|
37
65
|
text: heading.textContent || "",
|
|
38
66
|
level: parseInt(heading.tagName.replace("H", "")),
|
|
39
67
|
};
|
|
40
68
|
});
|
|
41
69
|
|
|
42
70
|
setHeadings(generatedHeadings);
|
|
43
|
-
},
|
|
71
|
+
}, 100); // 딜레이를 약간 주어 DOM이 완전히 안정화된 후 실행
|
|
44
72
|
|
|
45
73
|
return () => clearTimeout(timer);
|
|
46
|
-
}, [pathname]); // 페이지 변경사항이
|
|
74
|
+
}, [pathname, mounted]); // 페이지 변경사항이 있거나 마운트된 경우 실행
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!mounted || headings.length === 0) return;
|
|
78
|
+
|
|
79
|
+
const observer = new IntersectionObserver(
|
|
80
|
+
(entries) => {
|
|
81
|
+
const visibleEntries = entries.filter((entry) => entry.isIntersecting);
|
|
82
|
+
|
|
83
|
+
if (visibleEntries.length > 0) {
|
|
84
|
+
// 뷰포트 상단에 가장 가까운 요소를 찾아 활성화
|
|
85
|
+
const topVisible = visibleEntries.reduce((prev, curr) =>
|
|
86
|
+
curr.boundingClientRect.top < prev.boundingClientRect.top ? curr : prev
|
|
87
|
+
);
|
|
88
|
+
setActiveId(topVisible.target.id);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
// 상단 10% ~ 20% 지점을 타겟으로 감지
|
|
93
|
+
rootMargin: "-10% 0% -80% 0%",
|
|
94
|
+
threshold: 0,
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
headings.forEach((heading) => {
|
|
99
|
+
const el = document.getElementById(heading.id);
|
|
100
|
+
if (el) observer.observe(el);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return () => observer.disconnect();
|
|
104
|
+
}, [headings, mounted]);
|
|
105
|
+
|
|
106
|
+
if (!mounted) {
|
|
107
|
+
return <nav className="space-y-2 flex flex-col min-h-[100px]" />;
|
|
108
|
+
}
|
|
47
109
|
|
|
48
110
|
return (
|
|
49
|
-
<nav className="space-y-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
111
|
+
<nav className="flex flex-col space-y-1 border-l border-border-standard ml-2 py-1">
|
|
112
|
+
<div className="text-[10px] font-bold text-text-sub/60 uppercase tracking-widest mb-3 px-4">
|
|
113
|
+
On this page
|
|
114
|
+
</div>
|
|
115
|
+
{headings.length > 0 ? (
|
|
116
|
+
headings.map((heading, idx) => {
|
|
117
|
+
const isActive = activeId === heading.id;
|
|
118
|
+
return (
|
|
119
|
+
<a
|
|
120
|
+
key={`${heading.id}-${idx}`}
|
|
121
|
+
href={`#${heading.id}`}
|
|
122
|
+
style={{ paddingLeft: `${(heading.level - 1) * 12 + 16}px` }}
|
|
123
|
+
className={`text-xs py-1.5 pr-4 border-l-2 transition-all duration-200 ease-in-out ${
|
|
124
|
+
isActive
|
|
125
|
+
? "text-primary bg-primary/5 border-primary font-medium"
|
|
126
|
+
: "text-text-sub border-transparent hover:text-primary hover:bg-primary/5 hover:border-primary/30"
|
|
127
|
+
}`}
|
|
128
|
+
onClick={(e) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
const element = document.getElementById(heading.id);
|
|
131
|
+
if (element) {
|
|
132
|
+
// GSAP scrollTo를 사용하여 부드럽게 이동
|
|
133
|
+
// element.closest('.overflow-auto') 등을 통해 스크롤 컨테이너를 찾습니다.
|
|
134
|
+
const container = element.closest('.overflow-auto') || window;
|
|
135
|
+
|
|
136
|
+
gsap.to(container, {
|
|
137
|
+
duration: 0.8,
|
|
138
|
+
scrollTo: {
|
|
139
|
+
y: element,
|
|
140
|
+
offsetY: 80, // 상단 여백
|
|
141
|
+
autoKill: true
|
|
142
|
+
},
|
|
143
|
+
ease: "power2.inOut"
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 클릭 시 즉시 활성화 상태 업데이트
|
|
147
|
+
setActiveId(heading.id);
|
|
148
|
+
|
|
149
|
+
// URL 해시 업데이트
|
|
150
|
+
window.history.pushState(null, "", `#${heading.id}`);
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{heading.text}
|
|
155
|
+
</a>
|
|
156
|
+
);
|
|
157
|
+
})
|
|
158
|
+
) : (
|
|
159
|
+
<div className="px-4 text-[10px] text-text-sub/40 italic mt-2">
|
|
160
|
+
목차를 찾을 수 없습니다
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
61
163
|
</nav>
|
|
62
164
|
);
|
|
63
165
|
}
|