atomsystem 0.1.47 → 0.1.49

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.
Files changed (62) hide show
  1. package/README.md +153 -55
  2. package/atom.design.md +155 -0
  3. package/atom.uxPattern.md +431 -0
  4. package/components/ui/Badge.tsx +20 -0
  5. package/components/ui/Button.tsx +33 -0
  6. package/components/ui/Card.tsx +20 -0
  7. package/components/ui/Input.tsx +35 -0
  8. package/components/ui/Modal.tsx +54 -0
  9. package/components/ui/index.ts +5 -0
  10. package/css/atom-ui.css +216 -0
  11. package/dist/cli.cjs +105 -0
  12. package/dist/cli.cjs.map +1 -0
  13. package/dist/cli.d.cts +1 -0
  14. package/dist/cli.d.ts +1 -0
  15. package/dist/cli.js +97 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/index.cjs +7897 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.d.cts +218 -0
  20. package/dist/index.d.ts +218 -0
  21. package/dist/index.js +7845 -447
  22. package/dist/index.js.map +1 -0
  23. package/package.json +120 -81
  24. package/presets/default.design.md +155 -0
  25. package/presets/uxPattern.md +431 -0
  26. package/skills/atomsystem.skill.md +104 -0
  27. package/tokens/token.json +319 -0
  28. package/tokens/tokens.json +318 -976
  29. package/app/templates/Animations/Animation.tsx +0 -282
  30. package/app/templates/Badge/Badge.tsx +0 -28
  31. package/app/templates/Badge/badge.css +0 -28
  32. package/app/templates/Button/Button.tsx +0 -31
  33. package/app/templates/Button/button.css +0 -135
  34. package/app/templates/Card/Card.tsx +0 -50
  35. package/app/templates/Card/card.css +0 -17
  36. package/app/templates/CodeBlock/CodeBlock.tsx +0 -68
  37. package/app/templates/Collapse/Collapse.tsx +0 -207
  38. package/app/templates/Collapse/collapse.css +0 -24
  39. package/app/templates/Dropdown/Dropdown.css +0 -112
  40. package/app/templates/Dropdown/Dropdown.tsx +0 -103
  41. package/app/templates/Form/Form.tsx +0 -289
  42. package/app/templates/Form/form.css +0 -115
  43. package/app/templates/Modal/Modal.tsx +0 -71
  44. package/app/templates/Modal/modal.css +0 -98
  45. package/app/templates/Navigation/Navigation.tsx +0 -169
  46. package/app/templates/Navigation/navigation.css +0 -59
  47. package/app/templates/Pagination/Pagination.tsx +0 -104
  48. package/app/templates/Pagination/pagination.css +0 -66
  49. package/app/templates/Sidebar/Sidebar.tsx +0 -139
  50. package/app/templates/Sidebar/sidebar.css +0 -4
  51. package/app/templates/TOC/TOC.tsx +0 -165
  52. package/app/templates/Tab/Tab.tsx +0 -117
  53. package/app/templates/Tab/tab.css +0 -56
  54. package/app/templates/Table/Table.tsx +0 -54
  55. package/app/templates/Table/table.css +0 -58
  56. package/app/templates/Theme/Theme.tsx +0 -121
  57. package/app/templates/Toast/Toast.tsx +0 -106
  58. package/app/templates/Toast/toast.css +0 -69
  59. package/build/build-tokens.js +0 -223
  60. package/build/css/_variables.css +0 -251
  61. package/build/typescript/theme.ts +0 -6
  62. package/css/text.css +0 -18
@@ -1,169 +0,0 @@
1
- "use client"
2
-
3
- import Link from "next/link";
4
- import { usePathname } from "next/navigation";
5
- import gsap from 'gsap';
6
- import { useGSAP } from '@gsap/react';
7
- import { useRef, createContext, useContext, useState } from "react";
8
-
9
- // Icons
10
- import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
11
-
12
-
13
- gsap.registerPlugin(useGSAP); // register the hook to avoid React version discrepancies
14
-
15
- // Navigation을 베이스 interface로 정의
16
- export interface Navigation {
17
- children: React.ReactNode;
18
- }
19
-
20
- export interface NavLink extends Navigation {
21
- href: string;
22
- }
23
-
24
- /**
25
- * NavContextProps: 네비게이션 상태를 하위 컴포넌트와 공유하기 위한 인터페이스
26
- * - isOpen: 메뉴가 열려 있는지 여부
27
- * - setOpen: 메뉴 상태를 변경하는 함수
28
- */
29
- interface NavContextProps {
30
- isOpen: boolean;
31
- setOpen: (open: boolean) => void;
32
- }
33
-
34
- // NavContext: 상위 NavItem의 상태를 하 하위 NavTrigger와 NavContainer에 전달하는 통로(우체국)
35
- const NavContext = createContext<NavContextProps | undefined>(undefined);
36
-
37
- /**
38
- * useNavContext: Context 정보를 쉽고 안전하게 꺼내 쓰기 위한 전용 커스텀 훅
39
- * - 부모인 NavItem이 없을 경우(undefined)에도 에러가 나지 않도록 기본값을 반환하는 안전 장치 포함
40
- */
41
- const useNavContext = () => {
42
- const context = useContext(NavContext);
43
- if (!context) {
44
- // 부모를 찾지 못했을 때 앱이 멈추지 않도록 기본 상태 반환
45
- return { isOpen: false, setOpen: () => {} };
46
- }
47
- return context;
48
- };
49
-
50
- // 최상위 Navigation 레이아웃 컴포넌트
51
- const Navigation = ({ children }: Navigation) => {
52
- return (
53
- <nav className="nav-menu flex items-center justify-between">
54
- {children}
55
- </nav>
56
- )
57
- }
58
-
59
- // 로고나 브랜드명을 위한 컴포넌트
60
- const NavBrand = ({children, href}: NavLink) => {
61
- return (
62
- <Link href={href} className="flex items-center h-full font-bold text-xl">{children}</Link>
63
- )
64
- }
65
-
66
- /**
67
- * NavItem: 개별 메뉴 항목 및 서브 메뉴의 상태를 관리하는 부모 컨테이너
68
- * - 각각의 NavItem은 자신만의 isOpen 상태를 독립적으로 가짐
69
- */
70
- const NavItem = ({children}: Navigation) => {
71
- const [isOpen, setIsOpen] = useState(false);
72
- const setOpen = (open: boolean) => setIsOpen(open);
73
-
74
- // 마우스가 메뉴 전체 영역을 완전히 벗어날 때 호출 (메뉴 닫기)
75
- const handleMouseLeave = () => setIsOpen(false);
76
-
77
- return (
78
- <NavContext.Provider value={{ isOpen, setOpen }}>
79
- <li
80
- className={`nav-item`}
81
- onMouseLeave={handleMouseLeave}
82
- >
83
- {children}
84
- </li>
85
- </NavContext.Provider>
86
- )
87
- }
88
-
89
- // 메뉴 항목들을 가로로 정렬해주는 리스트 컨테이너
90
- const NavList = ({children}: Navigation) => {
91
- return (
92
- <ul className="flex items-center gap-0">{children}</ul>
93
- )
94
- }
95
-
96
- // 일반적인 링크 메뉴 컴포넌트
97
- const NavLink = ({children, href}: NavLink) => {
98
- const pathname = usePathname();
99
- const active = pathname === href;
100
-
101
- return (
102
- <Link href={href} className={`nav-link ${active ? "active" : ""}`}>{children}</Link>
103
- )
104
- }
105
-
106
- /**
107
- * NavTrigger: 서브 메뉴를 여는 트리거 버튼
108
- * - 마우스를 올렸을 때(onMouseEnter) 부모 NavItem의 상태를 열림(true)으로 변경
109
- */
110
- const NavTrigger = ({children}: Navigation) => {
111
- const { isOpen, setOpen } = useNavContext();
112
-
113
- // 트리거 영역에 진입했을 때만 메뉴 열기 실행
114
- const handleMouseEnter = () => setOpen(true);
115
-
116
- return (
117
- <div
118
- className="nav-link flex items-center gap-1"
119
- onMouseEnter={handleMouseEnter}
120
- >
121
- {children}
122
- {/* 상태에 따라 화살표 아이콘 회전 애니메이션 클래스 적용 */}
123
- <KeyboardArrowDownIcon className={`nav-arrow ${isOpen ? "open" : ""}`} />
124
- </div>
125
- )
126
- }
127
-
128
- /**
129
- * NavContainer: 서브 메뉴의 실제 내용물이 담기는 컨테이너
130
- * - GSAP을 사용하여 나타날 때 부드러운 애니메이션 효과 적용
131
- */
132
- const NavContainer = ({children}: Navigation) => {
133
- const { isOpen } = useNavContext();
134
- const containerContentRef = useRef<HTMLUListElement>(null);
135
-
136
- // 메뉴가 열릴 때 실행되는 GSAP 애니메이션
137
- useGSAP(() => {
138
- if (isOpen && containerContentRef.current) {
139
- gsap.fromTo(containerContentRef.current,
140
- { opacity: 0, y: -10 }, // 시작 상태: 투명하고 살짝 위쪽
141
- { opacity: 1, y: 0, duration: 0.2, ease: "power2.out" } // 도착 상태: 불투명하고 제자리
142
- );
143
- }
144
- }, [isOpen]);
145
-
146
-
147
- // 닫혀 있을 때는 아예 렌더링하지 않음
148
- if (!isOpen) return null;
149
-
150
- return (
151
- <div className="nav-container">
152
- {/* 실제 박스 형태를 가진 내부 리스트 */}
153
- <ul ref={containerContentRef} className="nav-content">
154
- {children}
155
- </ul>
156
- </div>
157
- )
158
- }
159
-
160
-
161
- export {
162
- Navigation,
163
- NavBrand,
164
- NavList,
165
- NavItem,
166
- NavLink,
167
- NavTrigger,
168
- NavContainer
169
- }
@@ -1,59 +0,0 @@
1
- .nav-menu{
2
- background-color: var(--bg-card);
3
- padding: 0 var(--spacing-common-x);
4
- border-bottom: 1px solid var(--border-standard);
5
- position: relative;
6
- }
7
-
8
- .nav-item{
9
- display: block;
10
- border-radius: var(--radius-common);
11
- padding: var(--spacing-common-y) 0;
12
- color: var(--text-standard);
13
- }
14
-
15
-
16
- .nav-link{
17
- display: block;
18
- padding: var(--spacing-common-y) var(--spacing-common-x);
19
- border-radius: var(--radius-common);
20
- cursor: pointer;
21
- }
22
-
23
- .nav-link:hover{
24
- background-color: var(--bg-hover);
25
- }
26
-
27
- .nav-link.active{
28
- color: var(--color-primary);
29
- }
30
-
31
- .nav-container {
32
- position: absolute;
33
- padding: var(--spacing-common-y) 0;
34
- top: 100%;
35
- left: 0;
36
- right: 0;
37
- width: 100%;
38
- z-index: 100;
39
- }
40
-
41
- .nav-content {
42
- display: flex;
43
- width: 100%;
44
- flex-wrap: wrap;
45
- gap: var(--spacing-common-x);
46
- background-color: var(--bg-card);
47
- border: 1px solid var(--border-standard);
48
- border-radius: var(--radius-common);
49
- padding: var(--spacing-common-y) var(--spacing-common-x);
50
- box-shadow: var(--shadow-standard);
51
- }
52
-
53
- .nav-arrow {
54
- transition: transform 0.2s ease;
55
- }
56
-
57
- .nav-arrow.open {
58
- transform: rotate(180deg);
59
- }
@@ -1,104 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
-
5
- const Pagination = ({
6
- pageRange,
7
- pageViewRange,
8
- totalCount,
9
- pageTotal,
10
- currentPage,
11
- onPageChange,
12
- }: {
13
- currentPage: number;
14
- onPageChange: (page: number) => void;
15
- /** 페이지당 데이터 개수 정보를 받습니다. */
16
- pageViewRange: number;
17
- /** 페이지네이션을 보여줄 최대 개수정보를 받습니다. */
18
- pageRange: number;
19
- /** 데이터의 총 개수를 받아 페이지당 데이터 개수로 나누어 총 페이지네이션 정보를 계산합니다. */
20
- totalCount?: number;
21
- /** 전체 페이지네이션의 토탈 정보를 받습니다. */
22
- pageTotal?: number;
23
- }) => {
24
- const totalPages = pageTotal
25
- ? pageTotal
26
- : totalCount
27
- ? Math.ceil(totalCount / pageViewRange)
28
- : 0;
29
- const [pageStart, setPageStart] = useState(1);
30
- const scrollTop = () => {
31
- setTimeout(() => {
32
- window.scrollTo({ top: 0, behavior: "smooth" });
33
- }, 0);
34
- };
35
-
36
- return (
37
- <div className="pagination">
38
- <button
39
- disabled={pageStart === 1}
40
- onClick={() => {
41
- scrollTop();
42
- const newStart = Math.max(1, pageStart - pageRange);
43
- setPageStart(newStart);
44
- onPageChange(newStart);
45
- }}
46
- className="pagination-btn"
47
- >
48
- <span className="material-symbols-outlined">keyboard_arrow_left</span>
49
- </button>
50
- <ul className="pagination-list">
51
- {[...Array(pageRange)].map((_, i) => {
52
- const page = pageStart + i;
53
-
54
- const isFirstIndex = i === 0;
55
- const isLastIndex = i === pageRange - 1;
56
- const isLastPageInRange = page === totalPages;
57
-
58
- if (page > totalPages) return null;
59
-
60
- return (
61
- <li
62
- key={page}
63
- onClick={() => {
64
- onPageChange(page);
65
-
66
- // 첫 번째 인덱스 클릭 시 이전 범위로
67
- if (isFirstIndex && pageStart > 1) {
68
- setPageStart(Math.max(1, pageStart - pageRange + 1));
69
- }
70
-
71
- // 마지막 인덱스 클릭 시 다음 범위로
72
- if (isLastIndex && !isLastPageInRange) {
73
- setPageStart(pageStart + pageRange - 1);
74
- }
75
- scrollTop();
76
- }}
77
- className={`pagination-item ${
78
- currentPage === page ? "pagination-item-active" : ""
79
- }`}
80
- >
81
- {page}
82
- </li>
83
- );
84
- })}
85
- </ul>
86
- <button
87
- disabled={pageStart + pageRange > totalPages}
88
- onClick={() => {
89
- scrollTop();
90
- const newStart = pageStart + pageRange;
91
- if (newStart <= totalPages) {
92
- setPageStart(newStart);
93
- onPageChange(newStart);
94
- }
95
- }}
96
- className="pagination-btn"
97
- >
98
- <span className="material-symbols-outlined">keyboard_arrow_right</span>
99
- </button>
100
- </div>
101
- );
102
- };
103
-
104
- export default Pagination;
@@ -1,66 +0,0 @@
1
- .pagination {
2
- display: flex;
3
- align-items: center;
4
- color: var(--color-text-standard);
5
- }
6
-
7
- .pagination-btn {
8
- padding: var(--spacing-common-y) var(--spacing-common-x);
9
- cursor: pointer;
10
- background: transparent;
11
- border: none;
12
- border-radius: var(--radius-common);
13
- color: inherit;
14
- transition: all 0.2s ease;
15
- display: flex;
16
- align-items: center;
17
- justify-content: center;
18
- }
19
-
20
- .pagination-btn:hover:not(:disabled) {
21
- background-color: var(--color-pagination-bg-hover);
22
- color: var(--color-primary);
23
- }
24
-
25
- .pagination-btn:disabled {
26
- opacity: 0.4;
27
- cursor: not-allowed;
28
- color: var(--color-text-disabled);
29
- }
30
-
31
- .pagination-list {
32
- display: flex;
33
- width: 100%;
34
- list-style: none;
35
- padding: 0;
36
- margin: 0;
37
- }
38
-
39
- .pagination-item {
40
- flex: 1; /* w-full behavior mapped */
41
- text-align: center;
42
- padding: var(--spacing-common-y) var(--spacing-common-x);
43
- cursor: pointer;
44
- color: inherit;
45
- border-radius: var(--radius-common);
46
- transition: all 0.2s ease;
47
- user-select: none;
48
- display: flex;
49
- align-items: center;
50
- justify-content: center;
51
- }
52
-
53
- .pagination-item:hover {
54
- background-color: var(--color-pagination-bg-hover);
55
- color: var(--color-primary);
56
- }
57
-
58
- .pagination-item-active {
59
- color: var(--color-primary);
60
- font-weight: var(--font-weight-header);
61
- }
62
-
63
- .pagination-item-active:hover {
64
- background-color: transparent; /* Reset hover if it's the active one */
65
- cursor: default;
66
- }
@@ -1,139 +0,0 @@
1
- "use client"
2
-
3
- import Link from "next/link";
4
- import { usePathname } from "next/navigation";
5
- import gsap from 'gsap';
6
- import { useGSAP } from '@gsap/react';
7
- import { createContext, useContext, useRef, useState } from "react";
8
-
9
- // Icons
10
- import MenuIcon from '@mui/icons-material/Menu';
11
-
12
- gsap.registerPlugin(useGSAP); // register the hook to avoid React version discrepancies
13
-
14
- // Navigation을 베이스 interface로 정의
15
- export interface Sidebar {
16
- children: React.ReactNode;
17
- minWidth?: number
18
- }
19
-
20
- export interface NavLink {
21
- children: React.ReactNode;
22
- href: string;
23
- }
24
-
25
- interface SideContext {
26
- isOpen: boolean;
27
- setToggle: (open: boolean) => void;
28
- }
29
-
30
- const SideContext = createContext<SideContext | undefined>(undefined);
31
-
32
- const useSideContext = () => {
33
- const context = useContext(SideContext);
34
- if (!context) {
35
- return { isOpen: false, setToggle: () => {} };
36
- }
37
- return context;
38
- }
39
-
40
- const SidebarProvider = ({ children }: { children: React.ReactNode }) => {
41
- const [isOpen, setIsOpen] = useState(true);
42
- const setToggle = (open: boolean) => setIsOpen(open);
43
-
44
- return (
45
- <SideContext.Provider value={{ isOpen, setToggle }}>
46
- {children}
47
- </SideContext.Provider>
48
- );
49
- };
50
-
51
- const SidebarToggle = () => {
52
- const { isOpen, setToggle } = useSideContext();
53
- return (
54
- <button className="cursor-pointer" onClick={() => setToggle(!isOpen)}><MenuIcon className="text-text-sub" sx={{fontSize: "20px"}}/></button>
55
- )
56
- }
57
-
58
- const Sidebar = ({ children, minWidth }: Sidebar) => {
59
- const { isOpen } = useSideContext();
60
- const sidebarRef = useRef<HTMLDivElement>(null);
61
-
62
- useGSAP(() => {
63
- if (sidebarRef.current) {
64
- if (isOpen) {
65
- // 열릴 때: 나타나면서 제자리로
66
- gsap.to(sidebarRef.current, {
67
- display: "flex",
68
- opacity: 1,
69
- x: 0,
70
- width: minWidth,
71
- minWidth: minWidth,
72
- duration: 0.3,
73
- ease: "power2.out"
74
- });
75
- } else {
76
- // 닫힐 때: 사라지면서 왼쪽으로, 너비도 0으로
77
- gsap.to(sidebarRef.current, {
78
- opacity: 0,
79
- x: -20,
80
- width: 0,
81
- minWidth: 0,
82
- duration: 0.3,
83
- ease: "power2.in",
84
- onComplete: () => {
85
- gsap.set(sidebarRef.current, { display: "none" });
86
- }
87
- });
88
- }
89
- }
90
- }, [isOpen]);
91
-
92
-
93
- return (
94
- <aside ref={sidebarRef} className={`nav-side flex flex-col overflow-auto h-full`} style={{ width: isOpen ? minWidth : 0, minWidth: isOpen ? minWidth : 0 }}>
95
- {children}
96
- </aside>
97
- )
98
- }
99
-
100
-
101
- const SideNavBrand = ({children, href}: NavLink) => {
102
- return (
103
- <Link href={href} className="flex items-center">{children}</Link>
104
- )
105
- }
106
-
107
- const SideNavList = ({children}: Sidebar) => {
108
- return (
109
- <ul className="nav-list flex flex-col">{children}</ul>
110
- )
111
- }
112
-
113
-
114
- const SideNavItem = ({children}: Sidebar) => {
115
- return (
116
- <li>{children}</li>
117
- )
118
- }
119
-
120
- const SideNavLink = ({children, href}: NavLink) => {
121
-
122
- const pathname = usePathname();
123
- const active = pathname === href;
124
-
125
- return (
126
- <Link href={href} className={`nav-link text-nowrap ${active ? "active" : ""}`}>{children}</Link>
127
- )
128
- }
129
-
130
-
131
- export {
132
- Sidebar,
133
- SidebarProvider,
134
- SidebarToggle,
135
- SideNavBrand,
136
- SideNavList,
137
- SideNavItem,
138
- SideNavLink
139
- }
@@ -1,4 +0,0 @@
1
- .nav-side{
2
- background-color: var(--bg-card);
3
- border-right: 1px solid var(--border-standard);
4
- }
@@ -1,165 +0,0 @@
1
- "use client";
2
- import { usePathname } from "next/navigation";
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
- }
10
-
11
- type Heading = {
12
- id: string;
13
- text: string;
14
- level: number;
15
- };
16
-
17
- export default function TOC() {
18
- const [headings, setHeadings] = useState<Heading[]>([]);
19
- const [activeId, setActiveId] = useState<string>("");
20
- const [mounted, setMounted] = useState(false);
21
-
22
- const pathname = usePathname();
23
-
24
- useEffect(() => {
25
- setMounted(true);
26
- }, []);
27
-
28
- useEffect(() => {
29
- if (!mounted) return;
30
-
31
- const timer = setTimeout(() => {
32
- /**
33
- * Document에서 읽히는 h1, h2, h3 태그를 array 형태로 정의
34
- * */
35
- const headingElements = Array.from(
36
- document.querySelectorAll("h1, h2, h3")
37
- ) as HTMLHeadingElement[];
38
-
39
- const usedIds = new Set<string>();
40
- const generatedHeadings: Heading[] = headingElements.map((heading) => {
41
- let finalId = heading.id;
42
-
43
- // ID가 없거나 중복된 경우 새로 생성
44
- if (!finalId || usedIds.has(finalId)) {
45
- const baseId =
46
- heading.textContent
47
- ?.toLowerCase()
48
- .trim()
49
- .replace(/\s+/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;
59
- }
60
-
61
- usedIds.add(finalId);
62
-
63
- return {
64
- id: finalId,
65
- text: heading.textContent || "",
66
- level: parseInt(heading.tagName.replace("H", "")),
67
- };
68
- });
69
-
70
- setHeadings(generatedHeadings);
71
- }, 100); // 딜레이를 약간 주어 DOM이 완전히 안정화된 후 실행
72
-
73
- return () => clearTimeout(timer);
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
- }
109
-
110
- return (
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
- )}
163
- </nav>
164
- );
165
- }