Skip to content

Commit 9d1f398

Browse files
committed
feat: 요소 접힘-펼침 기능 구현
1 parent 8847991 commit 9d1f398

File tree

1 file changed

+162
-0
lines changed

1 file changed

+162
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import ArrowSvg from "@assets/icons/ic_arrow.svg?react";
2+
import React, { useState, useEffect, useRef, PropsWithChildren } from "react";
3+
import styled from "styled-components";
4+
5+
const ExpandableContent: React.FC<
6+
PropsWithChildren<{ visibleLine?: number }>
7+
> = ({ children, visibleLine = 2 }) => {
8+
const contentRef = useRef<HTMLDivElement>(null);
9+
const [isExpanded, setIsExpanded] = useState(false);
10+
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
11+
const [baseHeight, setBaseHeight] = useState<number>(0);
12+
const [showEllipsis, setShowEllipsis] = useState(false);
13+
14+
useEffect(() => {
15+
const observer = new MutationObserver(() => {
16+
updateMaxHeight();
17+
});
18+
19+
updateMaxHeight();
20+
21+
contentRef.current &&
22+
observer.observe(contentRef.current, {
23+
childList: true,
24+
subtree: true,
25+
});
26+
27+
return () => observer.disconnect();
28+
}, []);
29+
30+
useEffect(() => {
31+
const contentElement = contentRef.current;
32+
if (contentElement) {
33+
const handleTransitionEnd = () => {
34+
const isClipped =
35+
contentElement.scrollHeight > contentElement.clientHeight;
36+
if (!isExpanded && isClipped) {
37+
setShowEllipsis(true);
38+
}
39+
};
40+
41+
contentElement.addEventListener("transitionend", handleTransitionEnd);
42+
43+
return () => {
44+
contentElement.removeEventListener(
45+
"transitionend",
46+
handleTransitionEnd,
47+
);
48+
};
49+
}
50+
}, [isExpanded]);
51+
52+
const updateMaxHeight = () => {
53+
const contentElement = contentRef.current;
54+
if (contentElement) {
55+
const childrenArray = Array.from(
56+
contentElement.children,
57+
) as HTMLElement[];
58+
const lineHeights: number[] = [];
59+
let currentTop: number | null = null;
60+
let currentLineHeight = 0;
61+
62+
childrenArray.forEach((child, index) => {
63+
if (currentTop === null) {
64+
currentTop = child.offsetTop;
65+
}
66+
67+
if (child.offsetTop === currentTop) {
68+
currentLineHeight = Math.max(currentLineHeight, child.offsetHeight);
69+
} else {
70+
lineHeights.push(currentLineHeight);
71+
currentTop = child.offsetTop;
72+
currentLineHeight = child.offsetHeight;
73+
}
74+
75+
if (index === childrenArray.length - 1) {
76+
lineHeights.push(currentLineHeight);
77+
}
78+
});
79+
80+
const gap = parseFloat(getComputedStyle(contentElement).gap);
81+
const totalHeight = lineHeights.reduce(
82+
(sum, height) => sum + height + gap,
83+
1,
84+
);
85+
86+
setMaxHeight(totalHeight);
87+
88+
const visibleLinesHeight = lineHeights
89+
.slice(0, visibleLine)
90+
.reduce(
91+
(sum, height, index) =>
92+
sum + height + (index < visibleLine - 1 ? gap : 0),
93+
0,
94+
);
95+
96+
setBaseHeight(visibleLinesHeight);
97+
}
98+
};
99+
100+
const toggleExpand = () => {
101+
setShowEllipsis(false);
102+
setIsExpanded(!isExpanded);
103+
};
104+
105+
return (
106+
<>
107+
<TagWrapper
108+
ref={contentRef}
109+
className={showEllipsis ? "clipped" : ""}
110+
style={{
111+
maxHeight: isExpanded ? `${maxHeight}px` : `${baseHeight}px`,
112+
overflow: "hidden",
113+
transition: "max-height 0.3s ease-in-out",
114+
}}
115+
>
116+
{children}
117+
</TagWrapper>
118+
{maxHeight && maxHeight > baseHeight && (
119+
<Button type="button" onClick={toggleExpand}>
120+
{isExpanded ? <Arrow /> : <Arrow className="collapse" />}
121+
</Button>
122+
)}
123+
</>
124+
);
125+
};
126+
127+
export default ExpandableContent;
128+
129+
const TagWrapper = styled.div`
130+
display: flex;
131+
gap: 0.5rem;
132+
flex-wrap: wrap;
133+
position: relative;
134+
135+
&.clipped::after {
136+
content: "...";
137+
position: absolute;
138+
right: 56px; // TODO: 동적으로 위치 조절할 수 있어야 함.
139+
bottom: 0;
140+
141+
${({ theme }) => theme.typo.caption1};
142+
letter-spacing: 0.6px;
143+
color: ${({ theme }) => theme.color.percentOrange};
144+
}
145+
`;
146+
147+
const Button = styled.button`
148+
margin-left: auto;
149+
`;
150+
151+
const Arrow = styled(ArrowSvg)`
152+
width: 20px;
153+
height: 20px;
154+
155+
transform: rotate(-90deg);
156+
157+
&.collapse {
158+
transform: rotate(90deg);
159+
}
160+
161+
transition: transform 0.2s ease-in-out;
162+
`;

0 commit comments

Comments
 (0)