Skip to content

Commit 6fc1e15

Browse files
authored
Merge pull request #131 from hufs-hexagon-talent/feat/date
date select & notice page change
2 parents 63e039e + c9ffbeb commit 6fc1e15

File tree

3 files changed

+380
-102
lines changed

3 files changed

+380
-102
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
'use client';
2+
3+
import React, {
4+
createContext,
5+
useCallback,
6+
useContext,
7+
useId,
8+
useMemo,
9+
useRef,
10+
useState,
11+
useEffect,
12+
} from 'react';
13+
import { ChevronDown } from 'lucide-react';
14+
15+
const AccordionCtx = createContext(null);
16+
17+
function useAccordionCtx() {
18+
const ctx = useContext(AccordionCtx);
19+
if (!ctx)
20+
throw new Error('Accordion components must be used within <Accordion>');
21+
return ctx;
22+
}
23+
24+
export function Accordion({
25+
type = 'single', // "single" | "multiple"
26+
defaultValue,
27+
value,
28+
onValueChange,
29+
collapsible = true,
30+
className,
31+
children,
32+
}) {
33+
const isControlled = value !== undefined;
34+
35+
const toSet = v =>
36+
new Set(v === undefined ? [] : Array.isArray(v) ? v : v ? [v] : []);
37+
const [internal, setInternal] = useState(toSet(defaultValue));
38+
const openValues = isControlled ? toSet(value) : internal;
39+
40+
const triggersRef = useRef([]);
41+
42+
const registerTrigger = useCallback(ref => {
43+
triggersRef.current.push(ref);
44+
return triggersRef.current.length - 1;
45+
}, []);
46+
47+
const focusByIndex = useCallback(index => {
48+
const list = triggersRef.current;
49+
if (!list.length) return;
50+
const clamped = (index + list.length) % list.length;
51+
list[clamped].current?.focus();
52+
}, []);
53+
54+
const setNext = useCallback(
55+
next => {
56+
if (isControlled) {
57+
const out = type === 'single' ? [...next][0] ?? '' : [...next];
58+
onValueChange?.(out);
59+
} else {
60+
setInternal(next);
61+
if (onValueChange) {
62+
const out = type === 'single' ? [...next][0] ?? '' : [...next];
63+
onValueChange(out);
64+
}
65+
}
66+
},
67+
[isControlled, onValueChange, type],
68+
);
69+
70+
const toggle = useCallback(
71+
v => {
72+
const next = new Set(openValues);
73+
if (type === 'single') {
74+
if (next.has(v)) {
75+
if (collapsible) next.clear();
76+
} else {
77+
next.clear();
78+
next.add(v);
79+
}
80+
} else {
81+
if (next.has(v)) next.delete(v);
82+
else next.add(v);
83+
}
84+
setNext(next);
85+
},
86+
[openValues, setNext, type, collapsible],
87+
);
88+
89+
const isOpen = useCallback(v => openValues.has(v), [openValues]);
90+
91+
const ctx = useMemo(
92+
() => ({
93+
type,
94+
collapsible,
95+
openValues,
96+
toggle,
97+
isOpen,
98+
registerTrigger,
99+
focusByIndex,
100+
}),
101+
[
102+
type,
103+
collapsible,
104+
openValues,
105+
toggle,
106+
isOpen,
107+
registerTrigger,
108+
focusByIndex,
109+
],
110+
);
111+
112+
// 리렌더마다 트리거 목록 재구성(중복 방지)
113+
useEffect(() => {
114+
triggersRef.current = [];
115+
});
116+
117+
return (
118+
<div data-slot="accordion" className={className}>
119+
<AccordionCtx.Provider value={ctx}>{children}</AccordionCtx.Provider>
120+
</div>
121+
);
122+
}
123+
124+
export function AccordionItem({ value, className, children }) {
125+
const { isOpen } = useAccordionCtx();
126+
const state = isOpen(value) ? 'open' : 'closed';
127+
128+
return (
129+
<div
130+
data-slot="accordion-item"
131+
data-state={state}
132+
className={['border-b last:border-b-0', className]
133+
.filter(Boolean)
134+
.join(' ')}>
135+
{React.Children.map(children, child => {
136+
if (!React.isValidElement(child)) return child;
137+
return React.cloneElement(child, {
138+
__itemValue: value,
139+
'data-state': state,
140+
});
141+
})}
142+
</div>
143+
);
144+
}
145+
146+
export function AccordionTrigger({
147+
className,
148+
children,
149+
__itemValue,
150+
...props
151+
}) {
152+
const { toggle, isOpen, registerTrigger, focusByIndex } = useAccordionCtx();
153+
if (!__itemValue)
154+
throw new Error('<AccordionTrigger> must be inside <AccordionItem>');
155+
156+
const open = isOpen(__itemValue);
157+
const btnRef = useRef(null);
158+
const indexRef = useRef(-1);
159+
160+
useEffect(() => {
161+
indexRef.current = registerTrigger(btnRef);
162+
}, [registerTrigger]);
163+
164+
const panelId = useId();
165+
const buttonId = useId();
166+
167+
const onKeyDown = e => {
168+
if (e.key === 'ArrowDown') {
169+
e.preventDefault();
170+
focusByIndex(indexRef.current + 1);
171+
} else if (e.key === 'ArrowUp') {
172+
e.preventDefault();
173+
focusByIndex(indexRef.current - 1);
174+
} else if (e.key === 'Home') {
175+
e.preventDefault();
176+
focusByIndex(0);
177+
} else if (e.key === 'End') {
178+
e.preventDefault();
179+
focusByIndex(9999);
180+
} else if (e.key === 'Enter' || e.key === ' ') {
181+
e.preventDefault();
182+
toggle(__itemValue);
183+
}
184+
};
185+
186+
return (
187+
<div className="flex">
188+
<button
189+
ref={btnRef}
190+
id={buttonId}
191+
data-slot="accordion-trigger"
192+
data-state={open ? 'open' : 'closed'}
193+
className={[
194+
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium',
195+
'transition-all outline-none hover:underline',
196+
'focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring',
197+
'disabled:pointer-events-none disabled:opacity-50',
198+
className,
199+
].join(' ')}
200+
aria-expanded={open}
201+
aria-controls={panelId}
202+
onClick={() => toggle(__itemValue)}
203+
onKeyDown={onKeyDown}
204+
{...props}>
205+
{children}
206+
<ChevronDown
207+
aria-hidden
208+
className={[
209+
'pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200',
210+
open ? 'rotate-180' : 'rotate-0',
211+
'text-muted-foreground',
212+
].join(' ')}
213+
/>
214+
</button>
215+
</div>
216+
);
217+
}
218+
219+
export function AccordionContent({
220+
className,
221+
children,
222+
__itemValue,
223+
...divProps
224+
}) {
225+
const { isOpen } = useAccordionCtx();
226+
if (!__itemValue)
227+
throw new Error('<AccordionContent> must be inside <AccordionItem>');
228+
const open = isOpen(__itemValue);
229+
230+
return (
231+
<div
232+
role="region"
233+
data-slot="accordion-content"
234+
data-state={open ? 'open' : 'closed'}
235+
className={[
236+
'overflow-hidden text-sm transition-[grid-template-rows] duration-300 ease-in-out',
237+
open ? 'grid grid-rows-[1fr]' : 'grid grid-rows-[0fr]',
238+
className,
239+
].join(' ')}
240+
{...divProps}>
241+
<div className="min-h-0 pt-0 pb-4 overflow-hidden">{children}</div>
242+
</div>
243+
);
244+
}

0 commit comments

Comments
 (0)