Skip to content

Commit 445dbc2

Browse files
committed
feat(common): accordion (#34)
1 parent 9c6d330 commit 445dbc2

File tree

4 files changed

+471
-1
lines changed

4 files changed

+471
-1
lines changed

src/components/Accordion/index.tsx

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import styled from '@emotion/styled';
2+
import * as React from 'react';
3+
4+
import useContext from '@/hooks/useContext';
5+
import useControllableState from '@/hooks/useControllableState';
6+
import { composeEventHandlers } from '@/libs/event';
7+
8+
interface AccordionContextType {
9+
openItems: string[];
10+
type: 'single' | 'multiple';
11+
toggle: (itemId: string) => void;
12+
}
13+
14+
const AccordionContext = React.createContext<AccordionContextType | null>(null);
15+
AccordionContext.displayName = 'AccordionContext';
16+
17+
interface AccordionItemContextType {
18+
value: string;
19+
isOpen: boolean;
20+
disabled: boolean;
21+
toggle: (itemId: string) => void;
22+
contentId: string;
23+
triggerId: string;
24+
}
25+
26+
const AccordionItemContext = React.createContext<AccordionItemContextType | null>(null);
27+
AccordionItemContext.displayName = 'AccordionItemContext';
28+
29+
type AccordionSingleProps = {
30+
/*
31+
Accordion type: single (default)
32+
*/
33+
type?: 'single';
34+
/*
35+
Default open accordion item value
36+
*/
37+
defaultValue?: string;
38+
} & React.ComponentPropsWithoutRef<'div'>;
39+
40+
type AccordionMultipleProps = {
41+
/*
42+
Accordion type: multiple
43+
*/
44+
type: 'multiple';
45+
/*
46+
Default open accordion items values
47+
*/
48+
defaultValue?: string[];
49+
} & React.ComponentPropsWithoutRef<'div'>;
50+
51+
type AccordionProps = AccordionSingleProps | AccordionMultipleProps;
52+
53+
export const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
54+
({ className, type = 'single', defaultValue, children, ...props }, ref) => {
55+
const getInitialValue = (): string[] => {
56+
if (!defaultValue) return [];
57+
if (type === 'single') return typeof defaultValue === 'string' ? [defaultValue] : [];
58+
return Array.isArray(defaultValue) ? defaultValue : [];
59+
};
60+
61+
const [openItems, setOpenItems] = useControllableState<string[]>({
62+
initValue: getInitialValue(),
63+
});
64+
65+
const toggle = React.useCallback(
66+
(itemId: string) => {
67+
setOpenItems(prev => {
68+
if (type === 'single') {
69+
return prev.includes(itemId) ? [] : [itemId];
70+
} else {
71+
return prev.includes(itemId) ? prev.filter(id => id !== itemId) : [...prev, itemId];
72+
}
73+
});
74+
},
75+
[type, setOpenItems],
76+
);
77+
78+
return (
79+
<AccordionRoot ref={ref} className={className} {...props}>
80+
<AccordionContext.Provider value={{ openItems, type, toggle }}>{children}</AccordionContext.Provider>
81+
</AccordionRoot>
82+
);
83+
},
84+
);
85+
Accordion.displayName = 'Accordion';
86+
87+
interface AccordionItemProps extends React.ComponentPropsWithoutRef<'div'> {
88+
/*
89+
Unique identifier for the accordion item
90+
*/
91+
value: string;
92+
/*
93+
Item disabled status
94+
*/
95+
disabled?: boolean;
96+
}
97+
98+
export const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
99+
({ className, value, disabled = false, children, ...props }, ref) => {
100+
const { openItems, toggle } = useContext(AccordionContext);
101+
const isOpen = openItems.includes(value);
102+
const contentId = React.useId();
103+
const triggerId = React.useId();
104+
105+
return (
106+
<AccordionItemContext.Provider
107+
value={{
108+
value,
109+
isOpen,
110+
disabled,
111+
toggle,
112+
contentId: `accordion-content-${contentId}`,
113+
triggerId: `accordion-trigger-${triggerId}`,
114+
}}
115+
>
116+
<AccordionItemContainer ref={ref} className={className} data-open={isOpen} data-disabled={disabled} {...props}>
117+
{children}
118+
</AccordionItemContainer>
119+
</AccordionItemContext.Provider>
120+
);
121+
},
122+
);
123+
AccordionItem.displayName = 'AccordionItem';
124+
125+
interface AccordionTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
126+
/*
127+
Custom indicator element
128+
*/
129+
indicator?: React.ReactNode;
130+
}
131+
132+
export const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
133+
({ className, indicator, onClick, children, ...props }, ref) => {
134+
const { isOpen, disabled, toggle, value, contentId, triggerId } = useContext(AccordionItemContext);
135+
136+
return (
137+
<AccordionHead
138+
ref={ref}
139+
className={className}
140+
type="button"
141+
disabled={disabled}
142+
aria-expanded={isOpen}
143+
aria-controls={contentId}
144+
id={triggerId}
145+
data-open={isOpen}
146+
onClick={composeEventHandlers(onClick, () => toggle(value))}
147+
{...props}
148+
>
149+
<AccordionTitle>{children}</AccordionTitle>
150+
<AccordionIndicator data-open={isOpen} aria-hidden>
151+
{indicator ?? (
152+
<Icon viewBox="0 0 24 24">
153+
<path d="M6 9l6 6l6-6" />
154+
</Icon>
155+
)}
156+
</AccordionIndicator>
157+
</AccordionHead>
158+
);
159+
},
160+
);
161+
AccordionTrigger.displayName = 'AccordionTrigger';
162+
163+
export const AccordionContent = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
164+
({ className, children, ...props }, ref) => {
165+
const { isOpen, contentId, triggerId } = useContext(AccordionItemContext);
166+
const innerRef = React.useRef<HTMLDivElement | null>(null);
167+
const [contentHeight, setContentHeight] = React.useState<number>(0);
168+
169+
React.useLayoutEffect(() => {
170+
if (innerRef.current) {
171+
setContentHeight(innerRef.current.scrollHeight);
172+
}
173+
}, [children]);
174+
175+
return (
176+
<AccordionContentWrapper
177+
ref={ref}
178+
className={className}
179+
id={contentId}
180+
role="region"
181+
aria-labelledby={triggerId}
182+
aria-hidden={!isOpen}
183+
data-open={isOpen}
184+
style={{ maxHeight: isOpen ? contentHeight : 0 }}
185+
{...props}
186+
>
187+
<AccordionContentInner ref={innerRef}>{children}</AccordionContentInner>
188+
</AccordionContentWrapper>
189+
);
190+
},
191+
);
192+
AccordionContent.displayName = 'AccordionContent';
193+
194+
const AccordionRoot = styled.div`
195+
display: flex;
196+
flex-direction: column;
197+
`;
198+
199+
const AccordionItemContainer = styled.div`
200+
border: 1px solid var(--gray-200);
201+
border-radius: 0.5rem;
202+
background-color: var(--background);
203+
overflow: hidden;
204+
205+
&:not(:last-child) {
206+
margin-bottom: 0.5rem;
207+
}
208+
`;
209+
210+
const AccordionHead = styled.button`
211+
width: 100%;
212+
display: inline-flex;
213+
align-items: center;
214+
justify-content: space-between;
215+
gap: 0.75rem;
216+
padding: 1rem;
217+
background: transparent;
218+
border: none;
219+
cursor: pointer;
220+
font-weight: 600;
221+
color: var(--gray-800);
222+
223+
&:focus-visible {
224+
outline: none;
225+
box-shadow: var(--focus-shadow);
226+
}
227+
228+
&:disabled {
229+
opacity: 0.6;
230+
cursor: not-allowed;
231+
}
232+
233+
&[data-open='true'] {
234+
border-bottom: 1px solid var(--gray-200);
235+
}
236+
`;
237+
238+
const AccordionTitle = styled.span`
239+
flex: 1;
240+
text-align: left;
241+
`;
242+
243+
const AccordionIndicator = styled.span`
244+
display: inline-flex;
245+
align-items: center;
246+
justify-content: center;
247+
transition: transform 200ms ease;
248+
249+
&[data-open='true'] {
250+
transform: rotate(180deg);
251+
}
252+
`;
253+
254+
const Icon = styled.svg`
255+
width: 1rem;
256+
height: 1rem;
257+
fill: none;
258+
stroke: currentColor;
259+
stroke-width: 2px;
260+
stroke-linecap: round;
261+
stroke-linejoin: round;
262+
`;
263+
264+
const AccordionContentWrapper = styled.div`
265+
overflow: hidden;
266+
transition:
267+
max-height 200ms ease,
268+
opacity 200ms ease;
269+
opacity: 0;
270+
271+
&[data-open='true'] {
272+
opacity: 1;
273+
}
274+
`;
275+
276+
const AccordionContentInner = styled.div`
277+
padding: 1rem;
278+
color: var(--gray-600);
279+
font-size: 0.875rem;
280+
line-height: 1.5;
281+
`;

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './Accordion';
12
export * from './Avatar';
23
export * from './Badge';
34
export * from './Button';

src/hooks/useControllableState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const useControllableState = <T>({
1313

1414
const setValue: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
1515
newValue => {
16-
setValueInternal(newValue); // 상태 업데이트
16+
setValueInternal(newValue); // State update
1717

1818
if (typeof onChange === 'function') {
1919
onChange(newValue as T);

0 commit comments

Comments
 (0)