atomsystem 0.1.48 → 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.
- package/README.md +153 -55
- package/atom.design.md +155 -0
- package/atom.uxPattern.md +431 -0
- package/components/ui/Badge.tsx +20 -0
- package/components/ui/Button.tsx +33 -0
- package/components/ui/Card.tsx +20 -0
- package/components/ui/Input.tsx +35 -0
- package/components/ui/Modal.tsx +54 -0
- package/components/ui/index.ts +5 -0
- package/css/atom-ui.css +216 -0
- package/dist/cli.cjs +105 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +97 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +7897 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +218 -0
- package/dist/index.d.ts +218 -0
- package/dist/index.js +7845 -451
- package/dist/index.js.map +1 -0
- package/package.json +120 -81
- package/presets/default.design.md +155 -0
- package/presets/uxPattern.md +431 -0
- package/skills/atomsystem.skill.md +104 -0
- package/tokens/token.json +319 -0
- package/tokens/tokens.json +318 -976
- package/app/templates/Animations/Animation.tsx +0 -282
- package/app/templates/Badge/Badge.tsx +0 -28
- package/app/templates/Badge/badge.css +0 -28
- package/app/templates/Button/Button.tsx +0 -31
- package/app/templates/Button/button.css +0 -135
- package/app/templates/Card/Card.tsx +0 -50
- package/app/templates/Card/card.css +0 -17
- package/app/templates/Checkbox/Checkbox.tsx +0 -55
- package/app/templates/Checkbox/checkbox.css +0 -38
- package/app/templates/CodeBlock/CodeBlock.tsx +0 -68
- package/app/templates/Collapse/Collapse.tsx +0 -207
- package/app/templates/Collapse/collapse.css +0 -24
- package/app/templates/Dropdown/Dropdown.css +0 -112
- package/app/templates/Dropdown/Dropdown.tsx +0 -103
- package/app/templates/Input/Input.tsx +0 -29
- package/app/templates/Input/input.css +0 -24
- package/app/templates/Label/Label.tsx +0 -22
- package/app/templates/Modal/Modal.tsx +0 -71
- package/app/templates/Modal/modal.css +0 -98
- package/app/templates/Navigation/Navigation.tsx +0 -169
- package/app/templates/Navigation/navigation.css +0 -59
- package/app/templates/Pagination/Pagination.tsx +0 -104
- package/app/templates/Pagination/pagination.css +0 -66
- package/app/templates/Radio/Radio.tsx +0 -45
- package/app/templates/Radio/radio.css +0 -29
- package/app/templates/Select/Select.tsx +0 -143
- package/app/templates/Select/select.css +0 -47
- package/app/templates/Sidebar/Sidebar.tsx +0 -139
- package/app/templates/Sidebar/sidebar.css +0 -4
- package/app/templates/TOC/TOC.tsx +0 -165
- package/app/templates/Tab/Tab.tsx +0 -117
- package/app/templates/Tab/tab.css +0 -56
- package/app/templates/Table/Table.tsx +0 -54
- package/app/templates/Table/table.css +0 -58
- package/app/templates/Theme/Theme.tsx +0 -122
- package/app/templates/Toast/Toast.tsx +0 -106
- package/app/templates/Toast/toast.css +0 -69
- package/build/build-tokens.js +0 -223
- package/build/css/_variables.css +0 -251
- package/build/typescript/theme.ts +0 -6
- package/css/text.css +0 -18
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
-
import { createPortal } from "react-dom";
|
|
4
|
-
import "./select.css";
|
|
5
|
-
|
|
6
|
-
export const Select = ({
|
|
7
|
-
options = [],
|
|
8
|
-
disabled,
|
|
9
|
-
}: {
|
|
10
|
-
options: string[];
|
|
11
|
-
disabled?: boolean;
|
|
12
|
-
}) => {
|
|
13
|
-
const [filterValue, setFilterValue] = useState("");
|
|
14
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
15
|
-
const [menuPosition, setMenuPosition] = useState({
|
|
16
|
-
top: 0,
|
|
17
|
-
left: 0,
|
|
18
|
-
width: 0,
|
|
19
|
-
});
|
|
20
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
21
|
-
const dropdownRef = useRef<HTMLUListElement>(null);
|
|
22
|
-
const filteredOptions = options.filter((option) =>
|
|
23
|
-
option.toLowerCase().includes(filterValue.toLowerCase())
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
useLayoutEffect(() => {
|
|
27
|
-
if (isOpen && inputRef.current && dropdownRef.current) {
|
|
28
|
-
const rect = inputRef.current.getBoundingClientRect();
|
|
29
|
-
const dropdownRect = dropdownRef.current.getBoundingClientRect();
|
|
30
|
-
|
|
31
|
-
if (Math.abs(menuPosition.width - rect.width) > 0.5) {
|
|
32
|
-
setMenuPosition({
|
|
33
|
-
top: rect.bottom + window.scrollY,
|
|
34
|
-
left: rect.left,
|
|
35
|
-
width: rect.width,
|
|
36
|
-
});
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
let top = rect.bottom + window.scrollY;
|
|
41
|
-
|
|
42
|
-
if (rect.bottom + dropdownRect.height > window.innerHeight) {
|
|
43
|
-
top = rect.top + window.scrollY - dropdownRect.height;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
setMenuPosition((prev) => {
|
|
47
|
-
if (
|
|
48
|
-
Math.abs(prev.top - top) < 0.5 &&
|
|
49
|
-
Math.abs(prev.left - rect.left) < 0.5
|
|
50
|
-
) {
|
|
51
|
-
return prev;
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
top,
|
|
55
|
-
left: rect.left,
|
|
56
|
-
width: rect.width,
|
|
57
|
-
};
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}, [isOpen, filterValue, menuPosition.width]);
|
|
61
|
-
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
64
|
-
if (
|
|
65
|
-
dropdownRef.current &&
|
|
66
|
-
!dropdownRef.current.contains(event.target as Node) &&
|
|
67
|
-
inputRef.current &&
|
|
68
|
-
!inputRef.current.contains(event.target as Node)
|
|
69
|
-
) {
|
|
70
|
-
setIsOpen(false);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
74
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
75
|
-
}, []);
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<div className="relative">
|
|
79
|
-
<label className="relative w-fit hidden md:block">
|
|
80
|
-
<input
|
|
81
|
-
ref={inputRef}
|
|
82
|
-
type="text"
|
|
83
|
-
value={filterValue}
|
|
84
|
-
placeholder="값을 선택해주세요..."
|
|
85
|
-
onChange={(e) => {
|
|
86
|
-
setFilterValue(e.target.value);
|
|
87
|
-
}}
|
|
88
|
-
onFocus={() => setIsOpen(true)}
|
|
89
|
-
className="select hidden sm:block"
|
|
90
|
-
disabled={disabled}
|
|
91
|
-
/>
|
|
92
|
-
<div
|
|
93
|
-
className="absolute flex items-center right-0 top-0 h-full px-2"
|
|
94
|
-
>
|
|
95
|
-
<span className="material-symbols-outlined">
|
|
96
|
-
<span className="text-base">arrow_drop_down</span>
|
|
97
|
-
</span>
|
|
98
|
-
</div>
|
|
99
|
-
</label>
|
|
100
|
-
{isOpen &&
|
|
101
|
-
createPortal(
|
|
102
|
-
<ul
|
|
103
|
-
ref={dropdownRef}
|
|
104
|
-
className="absolute w-full select-menu"
|
|
105
|
-
style={{
|
|
106
|
-
top: menuPosition.top,
|
|
107
|
-
left: menuPosition.left,
|
|
108
|
-
width: menuPosition.width,
|
|
109
|
-
}}
|
|
110
|
-
>
|
|
111
|
-
{filteredOptions.length > 0 ? (
|
|
112
|
-
filteredOptions.map((option, index) => (
|
|
113
|
-
<li
|
|
114
|
-
key={index}
|
|
115
|
-
className="select-menu-item"
|
|
116
|
-
onClick={() => {
|
|
117
|
-
setFilterValue(option);
|
|
118
|
-
setIsOpen(false);
|
|
119
|
-
}}
|
|
120
|
-
>
|
|
121
|
-
{option}
|
|
122
|
-
</li>
|
|
123
|
-
))
|
|
124
|
-
) : (
|
|
125
|
-
<li className="select-menu-item">No results</li>
|
|
126
|
-
)}
|
|
127
|
-
</ul>,
|
|
128
|
-
document.body
|
|
129
|
-
)}
|
|
130
|
-
<select
|
|
131
|
-
className="select block sm:hidden"
|
|
132
|
-
>
|
|
133
|
-
{options.map((option, index) => {
|
|
134
|
-
return (
|
|
135
|
-
<option key={index} value={option}>
|
|
136
|
-
{option}
|
|
137
|
-
</option>
|
|
138
|
-
);
|
|
139
|
-
})}
|
|
140
|
-
</select>
|
|
141
|
-
</div>
|
|
142
|
-
);
|
|
143
|
-
};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
.select{
|
|
2
|
-
background: var(--color-bg-input-standard);
|
|
3
|
-
border: 1px solid var(--color-border-standard);
|
|
4
|
-
color: var(--color-text-standard);
|
|
5
|
-
padding: var(--spacing-common-y) var(--spacing-common-x);
|
|
6
|
-
border-radius: var(--radius-common);
|
|
7
|
-
width: 100%;
|
|
8
|
-
outline: none;
|
|
9
|
-
cursor: pointer;
|
|
10
|
-
}
|
|
11
|
-
.select:focus{
|
|
12
|
-
border: 1px solid var(--color-border-active);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
.select:disabled{
|
|
16
|
-
background: var(--color-bg-input-disabled);
|
|
17
|
-
border: 1px solid var(--color-border-standard);
|
|
18
|
-
color: var(--color-text-disabled);
|
|
19
|
-
cursor: not-allowed;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
.select-menu {
|
|
23
|
-
border: 1px solid var(--color-border-active);
|
|
24
|
-
border-radius: var(--radius-common);
|
|
25
|
-
overflow:hidden;
|
|
26
|
-
z-index: 9999;
|
|
27
|
-
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
28
|
-
background: var(--color-bg-input-standard);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.select-menu .select-menu-item {
|
|
32
|
-
background: var(--color-bg-input-standard);
|
|
33
|
-
color: var(--color-text-standard);
|
|
34
|
-
padding: var(--spacing-common-y) var(--spacing-common-x);
|
|
35
|
-
cursor:pointer;
|
|
36
|
-
list-style: none;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
.select-menu .select-menu-item:hover {
|
|
40
|
-
background: var(--color-bg-input-hover);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.select-menu .select-menu-item:active,
|
|
44
|
-
.select-menu .select-menu-item:focus {
|
|
45
|
-
background: var(--color-primary);
|
|
46
|
-
color: var(--color-text-active);
|
|
47
|
-
}
|
|
@@ -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,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
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useRef, useState } from "react";
|
|
4
|
-
import gsap from "gsap";
|
|
5
|
-
import { useGSAP } from "@gsap/react";
|
|
6
|
-
|
|
7
|
-
const Tab = ({
|
|
8
|
-
tabs,
|
|
9
|
-
tabClass = "tab",
|
|
10
|
-
tabNavItemClass = "tab-nav-item",
|
|
11
|
-
tabNavItemActiveClass = "tab-nav-item-active",
|
|
12
|
-
tabContents,
|
|
13
|
-
tabContentClass = "tab-content",
|
|
14
|
-
tabContentItem = "tab-content-item",
|
|
15
|
-
expectionValue,
|
|
16
|
-
expectionLayout,
|
|
17
|
-
}: {
|
|
18
|
-
/**탭 네비게이션을 정의내립니다.
|
|
19
|
-
*/
|
|
20
|
-
tabs: Array<{
|
|
21
|
-
/** id는 탭 메뉴의 고유 값입니다. 고유 값을 통해 Active 상태 값을 관리합니다.*/
|
|
22
|
-
id: string;
|
|
23
|
-
/** target은 배열로 제공되며, 타겟의 배열과 동일한 내용을 tabContent로 적용됩니다.*/
|
|
24
|
-
target: string[];
|
|
25
|
-
/** 탭 메뉴의 ReactNode 내용을 받습니다.*/
|
|
26
|
-
tabItem: React.ReactNode;
|
|
27
|
-
}>;
|
|
28
|
-
/** 탭 메뉴의 tailwinds css 스타일 지정을 위한 class를 받습니다.*/
|
|
29
|
-
tabClass?: string;
|
|
30
|
-
/** 탭 메뉴의 각각의 tailwinds css 스타일 지정을 위한 class를 받습니다.*/
|
|
31
|
-
tabNavItemClass?: string;
|
|
32
|
-
/** 탭 메뉴의 활성화 상태의 tailwinds css 스타일 지정을 위한 class를 받습니다.*/
|
|
33
|
-
tabNavItemActiveClass?: string;
|
|
34
|
-
/** 탭 메뉴의 콘텐츠 값을 정의내립니다.*/
|
|
35
|
-
tabContents: Array<{
|
|
36
|
-
/** 고유 ID 값입니다.*/
|
|
37
|
-
id: string;
|
|
38
|
-
/** 태그 값으로 tabs target에서 선택된 내용만 화면에 제공됩니다.*/
|
|
39
|
-
tag: string;
|
|
40
|
-
/** 탭 메뉴의 콘텐츠 ReactNode 정보를 받습니다.*/
|
|
41
|
-
content: React.ReactNode;
|
|
42
|
-
}>;
|
|
43
|
-
/** 탭 메뉴의 콘텐츠 레이아웃을 위한한 tailwinds css 스타일 지정을 위한 class를 받습니다. */
|
|
44
|
-
tabContentClass?: string;
|
|
45
|
-
/** 탭 메뉴의 콘텐츠의 각 개별 요소를 위한 tailwinds css 스타일 지정을 위한 class를 받습니다. */
|
|
46
|
-
tabContentItem?: string;
|
|
47
|
-
/** 예외 레이아웃을 사용할 콘텐츠의 tag값을 입력받습니다. */
|
|
48
|
-
expectionValue?: string;
|
|
49
|
-
/** 예외 레이아웃에 사용하기 위한 tailwinds css 스타일 지정을 위한 class를 받습니다. */
|
|
50
|
-
expectionLayout?: string;
|
|
51
|
-
}) => {
|
|
52
|
-
const [activeContent, setActiveContent] = useState(tabs[0].target);
|
|
53
|
-
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
|
54
|
-
const tabContentRef = useRef<HTMLDivElement>(null);
|
|
55
|
-
|
|
56
|
-
const handleTabActive = (target: string[], id: string) => {
|
|
57
|
-
setActiveContent(target);
|
|
58
|
-
setActiveTab(id);
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// 👇 activeContent가 변경된 이후에 실행
|
|
62
|
-
useGSAP(() => {
|
|
63
|
-
if (tabContentRef.current) {
|
|
64
|
-
gsap.fromTo(
|
|
65
|
-
tabContentRef.current.children,
|
|
66
|
-
{ y: 50, opacity: 0 },
|
|
67
|
-
{
|
|
68
|
-
y: 0,
|
|
69
|
-
opacity: 1,
|
|
70
|
-
stagger: 0.1,
|
|
71
|
-
duration: 0.6,
|
|
72
|
-
ease: "power4.out",
|
|
73
|
-
}
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
}, [activeContent]);
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
<div className="flex flex-col w-full">
|
|
80
|
-
<div className={tabClass}>
|
|
81
|
-
{tabs.map((tab) => {
|
|
82
|
-
return (
|
|
83
|
-
<button
|
|
84
|
-
key={tab.id}
|
|
85
|
-
id={tab.id}
|
|
86
|
-
onClick={() => handleTabActive(tab.target, tab.id)}
|
|
87
|
-
className={`${tabNavItemClass} ${
|
|
88
|
-
activeTab == tab.id
|
|
89
|
-
? tabNavItemActiveClass
|
|
90
|
-
: ""
|
|
91
|
-
}`}
|
|
92
|
-
>
|
|
93
|
-
{tab.tabItem}
|
|
94
|
-
</button>
|
|
95
|
-
);
|
|
96
|
-
})}
|
|
97
|
-
</div>
|
|
98
|
-
<div ref={tabContentRef} className={tabContentClass}>
|
|
99
|
-
{tabContents.map((tabContent) => {
|
|
100
|
-
return activeContent.includes(tabContent.tag) ? (
|
|
101
|
-
<div
|
|
102
|
-
key={tabContent.id}
|
|
103
|
-
id={tabContent.tag}
|
|
104
|
-
className={`${tabContentItem} ${
|
|
105
|
-
activeTab == expectionValue ? expectionLayout : ""
|
|
106
|
-
}`}
|
|
107
|
-
>
|
|
108
|
-
{tabContent.content}
|
|
109
|
-
</div>
|
|
110
|
-
) : null;
|
|
111
|
-
})}
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
export default Tab;
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
.tab {
|
|
2
|
-
display: flex;
|
|
3
|
-
width: 100%;
|
|
4
|
-
background-color: var(--color-bg-wrapper);
|
|
5
|
-
border-bottom: 1px solid var(--color-border-standard);
|
|
6
|
-
position: relative;
|
|
7
|
-
z-index: 0;
|
|
8
|
-
height: 57px;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
.tab-nav-item {
|
|
12
|
-
padding: var(--spacing-common-x);
|
|
13
|
-
cursor: pointer;
|
|
14
|
-
border: 1px solid transparent; /* reserve space for active state border */
|
|
15
|
-
border-bottom: none;
|
|
16
|
-
background-color: transparent;
|
|
17
|
-
color: var(--color-text-standard);
|
|
18
|
-
transition: all 0.2s ease-in-out;
|
|
19
|
-
display: flex;
|
|
20
|
-
align-items: center;
|
|
21
|
-
justify-content: center;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
.tab-nav-item:hover {
|
|
25
|
-
background-color: var(--color-bg-card);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
.tab-nav-item-active {
|
|
29
|
-
background-color: var(--color-bg-card);
|
|
30
|
-
border: 1px solid var(--color-border-standard);
|
|
31
|
-
border-bottom: 1px solid var(--color-bg-card);
|
|
32
|
-
position: relative;
|
|
33
|
-
z-index: 10;
|
|
34
|
-
color: var(--color-primary);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
.tab-content {
|
|
38
|
-
background-color: var(--color-bg-card);
|
|
39
|
-
padding: var(--spacing-common-x);
|
|
40
|
-
border: 1px solid var(--color-border-standard);
|
|
41
|
-
border-top: 0;
|
|
42
|
-
margin-top: -1px; /* Overlap the top line of content with the bottom line of tab to allow the active tab to connect */
|
|
43
|
-
position: relative;
|
|
44
|
-
z-index: 1;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.tab-content-item {
|
|
48
|
-
color: var(--color-text-standard);
|
|
49
|
-
width: 100%;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
@media (min-width: 640px) {
|
|
53
|
-
.tab-content-item {
|
|
54
|
-
width: auto;
|
|
55
|
-
}
|
|
56
|
-
}
|